<?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-08-20T20:52:50+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">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><entry><title type="html">Run Salesforce in Copilot Cowork with no MCP server to deploy</title><link href="https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/2026-08-17-salesforce-hosted-mcp-cowork-plugin.html" rel="alternate" type="text/html" title="Run Salesforce in Copilot Cowork with no MCP server to deploy" /><published>2026-08-17T20:00:00+00:00</published><updated>2026-08-17T20:00:00+00:00</updated><id>https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/salesforce-hosted-mcp-cowork-plugin</id><content type="html" xml:base="https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/2026-08-17-salesforce-hosted-mcp-cowork-plugin.html"><![CDATA[<p>Salesforce operates MCP servers for its own platform, so a Cowork plugin for Salesforce doesn’t need an MCP server at all. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/Salesforce%20Hosted%20MCP">Salesforce for Copilot Cowork (Hosted MCP)</a> connects Copilot Cowork straight to <code class="language-plaintext highlighter-rouge">api.salesforce.com</code> — no container app, no Bicep, no Azure resource to keep patched.</p>

<p>This is the counterpart to my <a href="https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/2026-06-01-salesforce-cowork-plugin-mcp-server.html">self-hosted Salesforce Cowork plugin</a>, which ships a C# MCP server on Azure Container Apps. Same CRM workflows, opposite architecture.</p>

<h2 id="how-it-works">How it works</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Copilot Cowork
   |  MCP (streamable HTTP) + OAuth 2.0 / PKCE
   v
https://api.salesforce.com/platform/mcp/v1/platform/sobject-all
   |  runs as the signed-in Salesforce user
   v
Salesforce org - profile, sharing rules, field-level security enforced
</code></pre></div></div>

<p>The plugin contributes two things: a connector definition that points at the Salesforce endpoint, and nineteen skills that carry the CRM domain knowledge.</p>

<p>That second part is where the work went. The hosted server exposes <em>generic</em> sObject tools — <code class="language-plaintext highlighter-rouge">soqlQuery</code>, <code class="language-plaintext highlighter-rouge">getObjectSchema</code>, <code class="language-plaintext highlighter-rouge">createSobjectRecord</code> — not <code class="language-plaintext highlighter-rouge">search_accounts</code>-style domain tools. Without skills, the agent guesses field API names and SOQL syntax on every turn. The skills name the objects, the fields, the exact queries, how to read the results, and when to stop and ask before writing.</p>

<h2 id="tools-on-the-default-server">Tools on the default server</h2>

<p>The plugin targets <code class="language-plaintext highlighter-rouge">platform/sobject-all</code>, which provides eleven tools:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getObjectSchema</code></td>
      <td>Object index, or full field schema for one object</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">soqlQuery</code></td>
      <td>Run a SOQL query</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">find</code></td>
      <td>Run a SOSL text search</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getUserInfo</code></td>
      <td>Identity of the signed-in Salesforce user</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">listRecentSobjectRecords</code></td>
      <td>Recently viewed or modified records</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getRelatedRecords</code></td>
      <td>Child records via a relationship path</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">createSobjectRecord</code></td>
      <td>Create a record</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">updateSobjectRecord</code></td>
      <td>Update a record</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">updateRelatedSobjectRecord</code></td>
      <td>Update a child record via relationship</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deleteSobjectRecord</code></td>
      <td>Delete a record</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deleteRelatedSobjectRecord</code></td>
      <td>Delete a child record via relationship</td>
    </tr>
  </tbody>
</table>

<p>Salesforce publishes seven standard servers. Four cover sObjects at different permission levels, and three cover Data 360 and Tableau Next.</p>

<h2 id="nineteen-skills">Nineteen skills</h2>

<p>Ten read skills, eight write skills, and one meta skill:</p>

<table>
  <thead>
    <tr>
      <th>Skill</th>
      <th>Intent</th>
      <th>Mode</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">account-briefing</code></td>
      <td>Account 360 snapshot before a customer meeting</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">opportunity-health-summary</code></td>
      <td>Deal hygiene inspection and risk scoring</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pipeline-review</code></td>
      <td>Pipeline and forecast roll-ups by stage, owner, or period</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">open-risks-and-blockers</code></td>
      <td>Stalled deals, past-due dates, overdue tasks, escalations</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">next-best-action</code></td>
      <td>Evidence-based next move on a deal</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">find-contacts</code></td>
      <td>Locate contacts and leads, with deal context</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">review-tasks</code></td>
      <td>Triage open activities and upcoming meetings</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">lead-followup</code></td>
      <td>Untouched and aging lead triage</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">case-triage</code></td>
      <td>Open and escalated cases, correlated with revenue</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">explore-salesforce-data</code></td>
      <td>Schema discovery and ad-hoc SOQL on any object</td>
      <td>Read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create-account</code></td>
      <td>Create an account, duplicate-checked</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">update-account</code></td>
      <td>Edit account fields</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create-opportunity</code></td>
      <td>Create a deal tied to an account</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">update-opportunity</code></td>
      <td>Stage, amount, close date, next step, owner</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">add-contact</code></td>
      <td>Create a contact and link it to a deal</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">update-contact</code></td>
      <td>Edit contact fields and deal roles</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">log-call-notes</code></td>
      <td>Log calls and meetings, create follow-ups</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">delete-salesforce-record</code></td>
      <td>Guarded deletion with cascade disclosure</td>
      <td>Write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">improve-skills</code></td>
      <td>Capture skill misfires and report improvement insights</td>
      <td>Meta</td>
    </tr>
  </tbody>
</table>

<p>Every write skill shows a before/after diff and requires explicit confirmation before it calls a mutation tool. <code class="language-plaintext highlighter-rouge">delete-salesforce-record</code> goes further: it asks for confirmation by record name and Id, and discloses what the cascade will remove.</p>

<p>Two reference files carry the detail that would otherwise bloat every skill — a CRM object reference with field API names, picklist values, Id prefixes, and required fields, and a SOQL cookbook covering aggregates, relationships, escaping, error codes, and limits. Cowork loads them only when a skill needs them.</p>

<h2 id="pick-a-server-with-configureps1">Pick a server with configure.ps1</h2>

<p>Different orgs want different blast radius. <code class="language-plaintext highlighter-rouge">configure.ps1</code> rewrites <code class="language-plaintext highlighter-rouge">mcpServerUrl</code> and declares only the skills the target server can actually support:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-ListServers</span><span class="w">                    </span><span class="c"># see the options</span><span class="w">
</span><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-Server</span><span class="w"> </span><span class="nx">sobject-all</span><span class="w">             </span><span class="c"># full CRUD (default)</span><span class="w">
</span><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-Server</span><span class="w"> </span><span class="nx">sobject-reads</span><span class="w">           </span><span class="c"># read-only deployment</span><span class="w">
</span><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-Server</span><span class="w"> </span><span class="nx">sobject-all</span><span class="w"> </span><span class="nt">-Sandbox</span><span class="w">    </span><span class="c"># sandbox or scratch org</span><span class="w">
</span><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-Server</span><span class="w"> </span><span class="nx">sobject-all</span><span class="w"> </span><span class="nt">-ReferenceId</span><span class="w"> </span><span class="s2">"&lt;auth-config-id&gt;"</span><span class="w">
</span></code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Server</th>
      <th>Tools</th>
      <th>Skills</th>
      <th>Capability</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">platform/sobject-all</code></td>
      <td>11</td>
      <td>19 of 19</td>
      <td>Full CRUD — default</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">platform/sobject-reads</code></td>
      <td>6</td>
      <td>11 of 19</td>
      <td>Read-only</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">platform/sobject-mutations</code></td>
      <td>6</td>
      <td>12 of 19</td>
      <td>Create and update, no delete</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">platform/sobject-deletes</code></td>
      <td>5</td>
      <td>6 of 19</td>
      <td>Delete only</td>
    </tr>
  </tbody>
</table>

<p>Skill folders stay in the repo, so re-running with a broader server restores the fuller set. <code class="language-plaintext highlighter-rouge">preflight.ps1</code> fails the build if a hand-edited manifest declares a skill the target server can’t run.</p>

<p>The skill counts drop sharply on the mutation and delete servers because Salesforce exposes <code class="language-plaintext highlighter-rouge">getUserInfo</code> only on <code class="language-plaintext highlighter-rouge">sobject-reads</code> and <code class="language-plaintext highlighter-rouge">sobject-all</code>. Six skills resolve “my deals” and “my tasks” through it. Treat those two servers as narrow, purpose-built deployments rather than general CRM assistants.</p>

<h3 id="custom-servers">Custom servers</h3>

<p>Salesforce Setup can build a custom MCP server that composes sObject tools with Apex invocable methods, <code class="language-plaintext highlighter-rouge">@AuraEnabled</code> controllers, Apex REST endpoints, API Catalog entries, Flows, and Prompt Builder templates. Target one with:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-CustomServer</span><span class="w"> </span><span class="s2">"myorg/crm-plus"</span><span class="w">
</span><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-CustomServer</span><span class="w"> </span><span class="s2">"myorg/crm-readonly"</span><span class="w"> </span><span class="nt">-BaseOn</span><span class="w"> </span><span class="nx">sobject-reads</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-BaseOn</code> tells the script which standard tool set the custom server is assumed to include, so it can pick the skill set. Verify that assumption against the server’s real <code class="language-plaintext highlighter-rouge">tools/list</code> output — <code class="language-plaintext highlighter-rouge">preflight.ps1</code> can’t validate a custom server’s tools.</p>

<p>Custom servers are also the route around the plugin’s two biggest gaps. Lead conversion and record merge have no operation on the standard sObject servers. An admin can expose either as a Flow or <code class="language-plaintext highlighter-rouge">@InvocableMethod</code>, then add a skill that names that tool.</p>

<h2 id="authentication">Authentication</h2>

<p>Salesforce Hosted MCP uses OAuth 2.0 authorization code with PKCE, per user. Three facts drive the configuration.</p>

<p><strong>Dynamic client registration isn’t supported.</strong> Salesforce says so explicitly, so the connector can’t omit <code class="language-plaintext highlighter-rouge">authorization</code> and let Cowork self-register. Create an auth config up front in the Microsoft Enterprise token store and reference it with <code class="language-plaintext highlighter-rouge">authorization.type</code> of <code class="language-plaintext highlighter-rouge">OAuthPluginVault</code>.</p>

<p><strong>Connected Apps aren’t supported.</strong> You need an External Client App in the Salesforce org, with the <code class="language-plaintext highlighter-rouge">mcp_api</code> and <code class="language-plaintext highlighter-rouge">refresh_token</code> scopes and JWT-based access tokens for named users. Budget for propagation: the ECA can take up to 30 minutes to become operational.</p>

<p><strong>The redirect URI is fixed.</strong> Register <code class="language-plaintext highlighter-rouge">https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect</code> as the ECA callback URL. It’s identical for every Copilot plugin and every OAuth provider.</p>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>Production</th>
      <th>Sandbox / scratch</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Authorization URL</td>
      <td><code class="language-plaintext highlighter-rouge">https://login.salesforce.com/services/oauth2/authorize</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://test.salesforce.com/services/oauth2/authorize</code></td>
    </tr>
    <tr>
      <td>Token URL</td>
      <td><code class="language-plaintext highlighter-rouge">https://login.salesforce.com/services/oauth2/token</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://test.salesforce.com/services/oauth2/token</code></td>
    </tr>
    <tr>
      <td>Scopes</td>
      <td><code class="language-plaintext highlighter-rouge">mcp_api refresh_token</code></td>
      <td>same</td>
    </tr>
    <tr>
      <td>PKCE</td>
      <td>Required (S256)</td>
      <td>same</td>
    </tr>
    <tr>
      <td>MCP URL</td>
      <td><code class="language-plaintext highlighter-rouge">https://api.salesforce.com/platform/mcp/v1/&lt;SERVER-NAME&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://api.salesforce.com/platform/mcp/v1/sandbox/&lt;SERVER-NAME&gt;</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">offline_access</code> is a Microsoft identity platform scope. Salesforce uses <code class="language-plaintext highlighter-rouge">refresh_token</code> instead, which is already in the list. Adding <code class="language-plaintext highlighter-rouge">offline_access</code> here breaks the registration.</p>

<p>Every action runs as the signed-in Salesforce user. The plugin has no service account and can’t exceed that user’s profile, sharing rules, or field-level security.</p>

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

<ol>
  <li><strong>In Salesforce:</strong> create the External Client App with the Teams callback URL, add the <code class="language-plaintext highlighter-rouge">mcp_api</code> and <code class="language-plaintext highlighter-rouge">refresh_token</code> scopes, enable JWT-based access tokens for named users, and enable the MCP server under Setup &gt; Integration &gt; Salesforce MCP Servers. The server is off by default.</li>
  <li><strong>Verify in Postman before touching Cowork.</strong> Add the Postman callback to the ECA, create an MCP request over HTTP, authorize with PKCE, then confirm <code class="language-plaintext highlighter-rouge">tools/list</code> returns the eleven tools and <code class="language-plaintext highlighter-rouge">getUserInfo</code> returns the right user. If this fails, nothing downstream will work.</li>
  <li><strong>In Microsoft 365:</strong> create the OAuth auth config in <a href="https://dev.teams.microsoft.com/tools">Teams developer portal</a> &gt; <strong>Tools</strong> &gt; <strong>OAuth client registration</strong>. Supply the ECA consumer key and secret, the Salesforce endpoints, scopes <code class="language-plaintext highlighter-rouge">mcp_api refresh_token</code>, and PKCE. Set <strong>Base URL</strong> to the same value as <code class="language-plaintext highlighter-rouge">mcpServerUrl</code>. Capture the auth config ID.</li>
  <li>
    <p><strong>Configure, validate, and package:</strong></p>

    <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">"Cowork Plugins/Salesforce Hosted MCP"</span><span class="w">
</span><span class="o">.</span><span class="n">/configure.ps1</span><span class="w"> </span><span class="nt">-Server</span><span class="w"> </span><span class="nx">sobject-all</span><span class="w"> </span><span class="nt">-ReferenceId</span><span class="w"> </span><span class="s2">"&lt;auth-config-id&gt;"</span><span class="w">
</span><span class="o">.</span><span class="n">/preflight.ps1</span><span class="w">
</span><span class="o">.</span><span class="nx">/package.ps1</span><span class="w">
</span></code></pre></div>    </div>
  </li>
  <li>Upload <code class="language-plaintext highlighter-rouge">Salesforce Hosted MCP.zip</code> in the Microsoft 365 admin center, publish to test users, then connect in a <strong>fresh</strong> Cowork session. An existing session won’t pick up a newly published connector.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">preflight.ps1 -AllowPlaceholders</code> downgrades unresolved placeholders to warnings while you’re still developing.</p>

<h2 id="smoke-test-in-this-order">Smoke test in this order</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Who am I in Salesforce?

What's my Salesforce pipeline this quarter?

Brief me on [a real account].

What fields are on the Opportunity object?
</code></pre></div></div>

<p>Those four exercise <code class="language-plaintext highlighter-rouge">getUserInfo</code>, SOQL aggregates, <code class="language-plaintext highlighter-rouge">find</code> plus multi-query synthesis, and <code class="language-plaintext highlighter-rouge">getObjectSchema</code> in turn. Finish with a small write on a test record to confirm the confirmation gate fires before anything is saved.</p>

<h2 id="two-things-that-cost-me-time">Two things that cost me time</h2>

<p><strong>The manifest schema matters.</strong> This plugin uses <code class="language-plaintext highlighter-rouge">vDevPreview</code> (<code class="language-plaintext highlighter-rouge">manifestVersion: "devPreview"</code>), not <code class="language-plaintext highlighter-rouge">v1.28</code>. In Cowork’s current runtime, only the devPreview path binds the MCP connector. A <code class="language-plaintext highlighter-rouge">v1.28</code> manifest loads the skills and silently drops the connector, so the agent never invokes a tool and gives you no reason why. devPreview also wants <code class="language-plaintext highlighter-rouge">packageName</code> in reverse-DNS form and omits <code class="language-plaintext highlighter-rouge">mcpToolDescription</code> — Cowork discovers tools through MCP <code class="language-plaintext highlighter-rouge">tools/list</code>.</p>

<p><strong>Base URL must match <code class="language-plaintext highlighter-rouge">mcpServerUrl</code>.</strong> A mismatch in the Teams developer portal registration produces no clear error. Sign-in succeeds, the connector stays disconnected, and token exchange quietly fails.</p>

<h2 id="hosted-or-self-hosted">Hosted or self-hosted</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Hosted MCP (this plugin)</th>
      <th><a href="https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/2026-06-01-salesforce-cowork-plugin-mcp-server.html">Self-hosted</a></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Infrastructure</td>
      <td>None</td>
      <td>Azure Container App, App Insights, Bicep</td>
    </tr>
    <tr>
      <td>Time to first call</td>
      <td>Salesforce Setup only</td>
      <td><code class="language-plaintext highlighter-rouge">azd up</code> plus Salesforce Setup</td>
    </tr>
    <tr>
      <td>Tool surface</td>
      <td>Generic sObject tools, all objects</td>
      <td>16 purpose-built CRM tools</td>
    </tr>
    <tr>
      <td>Custom logic and shaping</td>
      <td>In skills only</td>
      <td>In C# server code</td>
    </tr>
    <tr>
      <td>Telemetry</td>
      <td>Salesforce-side</td>
      <td>Application Insights, fully controlled</td>
    </tr>
    <tr>
      <td>Custom objects</td>
      <td>Work immediately</td>
      <td>Require server changes</td>
    </tr>
    <tr>
      <td>Read-only variant</td>
      <td><code class="language-plaintext highlighter-rouge">configure.ps1 -Server sobject-reads</code></td>
      <td>Second <code class="language-plaintext highlighter-rouge">/mcp/federated</code> endpoint</td>
    </tr>
    <tr>
      <td>Ongoing maintenance</td>
      <td>Salesforce’s</td>
      <td>Yours</td>
    </tr>
  </tbody>
</table>

<p>Choose hosted MCP for breadth and zero infrastructure. Choose self-hosted when you need response shaping, custom telemetry, request-level policy, or tool definitions that hide the Salesforce data model from the agent.</p>

<h2 id="known-constraints">Known constraints</h2>

<ul>
  <li>Lead conversion and record merge have no operation on the standard sObject servers. Expose them as a Flow or invocable action on a custom server, or handle them in Salesforce.</li>
  <li>Opportunity products need a <code class="language-plaintext highlighter-rouge">Pricebook2Id</code> on the opportunity before <code class="language-plaintext highlighter-rouge">OpportunityLineItem</code> records can be created.</li>
  <li>SOQL returns at most 50,000 records per transaction; SOSL <code class="language-plaintext highlighter-rouge">find</code> returns at most 2,000.</li>
  <li>Deletions are recoverable for 15 days from the Recycle Bin. The connector has no undelete tool.</li>
  <li>Custom picklist values are the norm, so skills call <code class="language-plaintext highlighter-rouge">getObjectSchema</code> before writing picklist fields rather than assuming Salesforce defaults.</li>
</ul>

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

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/Salesforce%20Hosted%20MCP">Salesforce Hosted MCP Cowork plugin</a></li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/blob/main/Cowork%20Plugins/Salesforce%20Hosted%20MCP/SETUP-CHECKLIST.md">Setup checklist</a></li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring">SharingIsCaring repository</a></li>
  <li><a href="https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/2026-06-01-salesforce-cowork-plugin-mcp-server.html">Self-hosted Salesforce Cowork plugin</a></li>
  <li><a href="https://troystaylor.com/power%20platform/custom%20connectors/2026-06-12-salesforce-hosted-mcp-servers-copilot-studio.html">Salesforce Hosted MCP servers in Copilot Studio</a></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/extensibility/plugin-authentication">Configure plugin authentication</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="MCP (Model Context Protocol)" /><category term="Copilot Studio" /><category term="Integration" /><category term="Salesforce" /><category term="Microsoft 365 Copilot" /><category term="Copilot Cowork" /><category term="MCP" /><category term="Agent Skills" /><category term="Hosted MCP Servers" /><category term="SOQL" /><summary type="html"><![CDATA[A Copilot Cowork plugin that connects to Salesforce's own hosted MCP servers. Nineteen CRM skills, no container to deploy, and every action runs as the signed-in Salesforce user.]]></summary></entry><entry><title type="html">Bring GitHub workflows to Copilot Cowork</title><link href="https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/2026-08-14-github-cowork-plugin-remote-mcp-server.html" rel="alternate" type="text/html" title="Bring GitHub workflows to Copilot Cowork" /><published>2026-08-14T20:26:00+00:00</published><updated>2026-08-14T20:26:00+00:00</updated><id>https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/github-cowork-plugin-remote-mcp-server</id><content type="html" xml:base="https://troystaylor.com/mcp%20(model%20context%20protocol)/copilot%20studio/integration/2026-08-14-github-cowork-plugin-remote-mcp-server.html"><![CDATA[<p>Developers already ask GitHub Copilot to explain code and make changes. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/GitHub%20Cowork">GitHub for Copilot Cowork</a> brings those workflows into Microsoft 365 Copilot Cowork, where a conversation can start with an engineering report and end with a pull request assigned to the Copilot coding agent.</p>

<p>The plugin connects directly to <a href="https://github.com/github/github-mcp-server">GitHub’s hosted MCP server</a>. You don’t need to deploy an MCP server or maintain another Azure resource. Each user signs in with their own GitHub identity, so the connector sees only the repositories that person can access.</p>

<h2 id="what-the-plugin-includes">What the plugin includes</h2>

<p>The package contains:</p>

<ul>
  <li>A Microsoft 365 unified app manifest using schema version 1.30</li>
  <li>Six Agent Skills for common GitHub workflows</li>
  <li>A description file for 47 GitHub MCP tools</li>
  <li>OAuth configuration through the Microsoft Enterprise Token Store</li>
  <li>A PowerShell script that validates and packages the plugin</li>
  <li>An OAuth test script for separating GitHub failures from Cowork configuration failures</li>
</ul>

<p>The architecture stays small:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Copilot Cowork
    |
    |-- Agent Skills define workflows, output, and guardrails
    |
    |-- MCP connector sends JSON-RPC over HTTPS
    v
https://api.githubcopilot.com/mcp/
    |
    v
GitHub REST and GraphQL APIs
</code></pre></div></div>

<p>Most of my Cowork plugins use an in-tenant MCP server to keep a vendor-hosted intermediary out of the data path. GitHub is different because the remote endpoint is GitHub’s own first-party service. If your organization requires GitHub traffic to leave from an Azure subscription you control, you can self-host the open-source GitHub MCP server and change <code class="language-plaintext highlighter-rouge">mcpServerUrl</code>.</p>

<h2 id="six-github-skills">Six GitHub skills</h2>

<p>Raw tools expose operations. Skills turn those operations into work someone would delegate.</p>

<table>
  <thead>
    <tr>
      <th>Skill</th>
      <th>What it does</th>
      <th>Example request</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">explore-repositories</code></td>
      <td>Searches repositories and code, reads files, and checks history</td>
      <td>“Find the repository that handles OAuth callbacks”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">triage-issues</code></td>
      <td>Finds, creates, labels, assigns, and closes issues</td>
      <td>“Triage the unassigned bugs in this repository”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">review-pull-requests</code></td>
      <td>Reads diffs, checks status, comments, approves, and merges</td>
      <td>“Review the open pull requests waiting on me”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">release-notes</code></td>
      <td>Builds changelogs and shipped-feature summaries</td>
      <td>“Draft release notes for version 2.1”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">engineering-report</code></td>
      <td>Rolls up repository and team activity</td>
      <td>“Write a sprint summary and call out blockers”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">delegate-to-copilot</code></td>
      <td>Assigns implementation work to the GitHub Copilot coding agent</td>
      <td>“Have Copilot fix issue 42”</td>
    </tr>
  </tbody>
</table>

<p>The repository-search and issue-triage skills also include reference files. Search syntax and issue-field details load only when needed instead of taking space in every conversation.</p>

<h2 id="why-the-tool-description-file-matters">Why the tool description file matters</h2>

<p>The manifest points to <code class="language-plaintext highlighter-rouge">tools/github-tools.json</code>, which contains the tool names and input schemas packaged with the plugin:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"agentConnectors"</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">"github-mcp"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"GitHub"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"toolSource"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"remoteMcpServer"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"mcpServerUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.githubcopilot.com/mcp/"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"mcpToolDescription"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"file"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tools/github-tools.json"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"authorization"</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">"OAuthPluginVault"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"referenceId"</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><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>Cowork calls <code class="language-plaintext highlighter-rouge">initialize</code> and <code class="language-plaintext highlighter-rouge">tools/list</code> at runtime, but the packaged description file is still required for upload. Microsoft documents this requirement in <a href="https://learn.microsoft.com/microsoft-365/copilot/cowork/cowork-plugin-development">Build plugins for Copilot Cowork</a>.</p>

<h2 id="configure-github-oauth">Configure GitHub OAuth</h2>

<p>GitHub’s remote MCP server doesn’t support Dynamic Client Registration. Register a GitHub OAuth App or GitHub App, then connect it to an OAuth client registration in Teams Developer Portal.</p>

<p>A GitHub App gives production deployments better controls, including selectable repository access and expiring user tokens. An OAuth App takes fewer steps for a first test, so the instructions below use one.</p>

<h3 id="1-register-a-github-oauth-app">1. Register a GitHub OAuth App</h3>

<p>Open <a href="https://github.com/settings/developers">GitHub developer settings</a>, select <strong>OAuth Apps</strong>, and create an app with these values:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Application name</td>
      <td><code class="language-plaintext highlighter-rouge">GitHub for Copilot Cowork</code></td>
    </tr>
    <tr>
      <td>Homepage URL</td>
      <td>Your repository or company URL</td>
    </tr>
    <tr>
      <td>Authorization callback URL</td>
      <td><code class="language-plaintext highlighter-rouge">https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect</code></td>
    </tr>
  </tbody>
</table>

<p>Generate a client secret and copy it when GitHub displays it.</p>

<h3 id="2-register-the-oauth-client">2. Register the OAuth client</h3>

<p>Open <a href="https://dev.teams.microsoft.com/tools">Teams Developer Portal</a>, select <strong>Tools</strong> &gt; <strong>OAuth client registration</strong>, and add this configuration:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Registration name</td>
      <td><code class="language-plaintext highlighter-rouge">GitHub MCP</code></td>
    </tr>
    <tr>
      <td>Base URL</td>
      <td><code class="language-plaintext highlighter-rouge">https://api.githubcopilot.com/mcp</code></td>
    </tr>
    <tr>
      <td>Client ID</td>
      <td>GitHub OAuth App client ID</td>
    </tr>
    <tr>
      <td>Client secret</td>
      <td>GitHub OAuth App client secret</td>
    </tr>
    <tr>
      <td>Authorization endpoint</td>
      <td><code class="language-plaintext highlighter-rouge">https://github.com/login/oauth/authorize</code></td>
    </tr>
    <tr>
      <td>Token endpoint</td>
      <td><code class="language-plaintext highlighter-rouge">https://github.com/login/oauth/access_token</code></td>
    </tr>
    <tr>
      <td>Refresh endpoint</td>
      <td><code class="language-plaintext highlighter-rouge">https://github.com/login/oauth/access_token</code></td>
    </tr>
    <tr>
      <td>Scope</td>
      <td><code class="language-plaintext highlighter-rouge">repo read:org read:user offline_access</code></td>
    </tr>
    <tr>
      <td>Enable PKCE</td>
      <td>On</td>
    </tr>
  </tbody>
</table>

<p>Use <strong>My organization only</strong> while testing in one tenant. Use <strong>Any Microsoft 365 organization</strong> when preparing the plugin for broader distribution.</p>

<p>The two MCP URLs intentionally differ:</p>

<ul>
  <li>The manifest uses <code class="language-plaintext highlighter-rouge">https://api.githubcopilot.com/mcp/</code> <strong>with</strong> a trailing slash</li>
  <li>The OAuth registration uses <code class="language-plaintext highlighter-rouge">https://api.githubcopilot.com/mcp</code> <strong>without</strong> a trailing slash</li>
</ul>

<p>Using a trailing slash in the OAuth registration’s Base URL can let sign-in finish and then fail the connector binding.</p>

<p>The <code class="language-plaintext highlighter-rouge">repo</code> scope is broad because issue, pull request, and merge tools need write access to private repositories. GitHub doesn’t offer a narrower OAuth App scope that preserves those operations. Review this access before distributing the plugin.</p>

<p><code class="language-plaintext highlighter-rouge">offline_access</code> asks GitHub for an expiring access token and a refresh token. It doesn’t add repository permissions. Enable expiring user tokens on the OAuth App to use the stronger token posture.</p>

<h3 id="3-add-the-registration-id">3. Add the registration ID</h3>

<p>Teams Developer Portal returns an OAuth client registration ID after saving. Replace `` in <code class="language-plaintext highlighter-rouge">manifest.json</code> with that value.</p>

<p>The committed package keeps the placeholder by design. It can’t authenticate until you add a registration created for your tenant and rebuild the package.</p>

<h2 id="package-and-upload">Package and upload</h2>

<p>Run the packaging script from the plugin folder:</p>

<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">"Cowork Plugins\GitHub Cowork"</span><span class="w">
</span><span class="o">.</span><span class="n">\package.ps1</span><span class="w">
</span></code></pre></div></div>

<p>The script validates the manifest, skill folders, skill front matter, icons, tool description, and OAuth placeholder before creating the zip file.</p>

<p>Upload the package through <strong>Microsoft 365 admin center</strong> &gt; <strong>Agents</strong> &gt; <strong>Tools</strong> &gt; <strong>Registry</strong>. Choose the test users or groups that should receive it, then enable the GitHub source in a new Cowork session. Microsoft documents the admin flow in <a href="https://learn.microsoft.com/microsoft-365/admin/manage/manage-tools-for-agent">Manage tools for agents</a>.</p>

<p>Each user completes GitHub sign-in the first time a skill needs a tool. An administrator can’t authorize GitHub on someone else’s behalf.</p>

<h2 id="try-these-requests">Try these requests</h2>

<p>Start with a read operation before testing mutations:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Find my repositories related to Model Context Protocol.

Show open issues without an assignee in troystaylor/SharingIsCaring.

Review pull request 123 and summarize any merge risks.

Draft release notes from pull requests merged since August 1.

Create an engineering report for this repository covering the last seven days.

Assign issue 42 to the GitHub Copilot coding agent.
</code></pre></div></div>

<p>Confirm the target repository before creating issues, commenting, merging, or delegating work. The skills include mutation guardrails, but clear repository names make the conversation safer.</p>

<h2 id="fix-common-connection-failures">Fix common connection failures</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Check</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Connector fails after a successful sign-in</td>
      <td>Remove the trailing slash from the OAuth registration’s Base URL</td>
    </tr>
    <tr>
      <td>GitHub reports a redirect mismatch</td>
      <td>Set the callback URL exactly to <code class="language-plaintext highlighter-rouge">https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect</code></td>
    </tr>
    <tr>
      <td>Personal repositories work, but organization repositories return 404</td>
      <td>Ask the GitHub organization to approve the OAuth App</td>
    </tr>
    <tr>
      <td>Tools return 401 or 403</td>
      <td>Confirm the OAuth registration and installed plugin use the same Microsoft 365 tenant</td>
    </tr>
    <tr>
      <td>A scope or credential fix appears to have no effect</td>
      <td>Revoke the GitHub OAuth App grant, disconnect the source, and sign in again</td>
    </tr>
    <tr>
      <td>Token exchange still fails</td>
      <td>Run <code class="language-plaintext highlighter-rouge">.\Test-OAuthExchange.ps1</code> to test the client ID, secret, callback, and MCP call outside Cowork</td>
    </tr>
  </tbody>
</table>

<p>GitHub returns HTTP 200 for some token-exchange errors and puts the error in the response body. A generic connector error can therefore hide <code class="language-plaintext highlighter-rouge">incorrect_client_credentials</code>. The included test script prints the real response without storing or echoing the client secret.</p>

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

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/GitHub%20Cowork">GitHub Cowork plugin</a></li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring">SharingIsCaring repository</a></li>
  <li><a href="https://github.com/github/github-mcp-server">GitHub MCP server</a></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/cowork-manage-plugins">Manage plugins for Copilot Cowork</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/plugin-authentication">Configure plugin authentication</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="MCP (Model Context Protocol)" /><category term="Copilot Studio" /><category term="Integration" /><category term="GitHub" /><category term="Microsoft 365 Copilot" /><category term="Copilot Cowork" /><category term="MCP" /><category term="Agent Skills" /><category term="GitHub Copilot" /><summary type="html"><![CDATA[Connect Copilot Cowork to GitHub's hosted MCP server for repository search, issue triage, pull request reviews, release notes, engineering reports, and Copilot coding agent delegation.]]></summary></entry><entry><title type="html">Microsoft 365 Roadmap and Azure Updates in Copilot Studio</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-13-microsoft-release-communications-mcp-connector.html" rel="alternate" type="text/html" title="Microsoft 365 Roadmap and Azure Updates in Copilot Studio" /><published>2026-08-13T19:00:00+00:00</published><updated>2026-08-13T19:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/microsoft-release-communications-mcp-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-13-microsoft-release-communications-mcp-connector.html"><![CDATA[<p>Microsoft hosts a public MCP server for release communications at <code class="language-plaintext highlighter-rouge">https://www.microsoft.com/releasecommunications/mcp</code>. It answers questions about the Microsoft 365 Roadmap and Azure Updates — what’s in development, what ships this month, what’s being retired — with no API key, no license, and no tenant configuration. Point an IDE-based MCP client at it and it works.</p>

<p>Power Platform is the gap. Copilot Studio can consume an MCP server, but only through a connector, and a naive passthrough to this one fails on the first call. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Microsoft%20Release%20Communications%20MCP">Microsoft Release Communications MCP</a> is that connector, plus REST operations for the flows and apps that can’t speak MCP at all.</p>

<h2 id="five-operations-two-audiences">Five operations, two audiences</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Type</th>
      <th>Consumer</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">InvokeMCP</code></td>
      <td>MCP</td>
      <td>Copilot Studio agents</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ListM365RoadmapItems</code></td>
      <td>REST</td>
      <td>Power Automate, Power Apps</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ListAzureUpdates</code></td>
      <td>REST</td>
      <td>Power Automate, Power Apps</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GetM365RoadmapFeed</code></td>
      <td>RSS</td>
      <td>Recurrence-triggered flows</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GetAzureUpdatesFeed</code></td>
      <td>RSS</td>
      <td>Recurrence-triggered flows</td>
    </tr>
  </tbody>
</table>

<p>The MCP endpoint exposes four tools: <code class="language-plaintext highlighter-rouge">get_recent_m365_roadmaps</code>, <code class="language-plaintext highlighter-rouge">get_m365_roadmap_by_id</code>, <code class="language-plaintext highlighter-rouge">get_recent_azure_updates</code>, and <code class="language-plaintext highlighter-rouge">get_azure_update_by_id</code>.</p>

<p>The docs name the first one <code class="language-plaintext highlighter-rouge">get_recent_roadmaps</code>. The live server’s <code class="language-plaintext highlighter-rouge">tools/list</code> says <code class="language-plaintext highlighter-rouge">get_recent_m365_roadmaps</code>. Write agent instructions against the live name.</p>

<h2 id="three-upstream-behaviors-that-break-a-passthrough">Three upstream behaviors that break a passthrough</h2>

<p>The server is spec-compliant. That’s the problem — Power Platform’s MCP handling expects a narrower shape than the spec allows. <a href="https://github.com/troystaylor/SharingIsCaring/blob/main/Microsoft%20Release%20Communications%20MCP/script.csx">script.csx</a> handles each one.</p>

<p><strong>406 on an <code class="language-plaintext highlighter-rouge">Accept</code> header of <code class="language-plaintext highlighter-rouge">application/json</code>.</strong> Streamable HTTP requires the client to accept both content types, and the server enforces it. Every call fails until you send both:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// The MRC MCP Server returns 406 unless the caller accepts both JSON and SSE.</span>
<span class="n">forward</span><span class="p">.</span><span class="n">Headers</span><span class="p">.</span><span class="n">Accept</span><span class="p">.</span><span class="nf">Clear</span><span class="p">();</span>
<span class="n">forward</span><span class="p">.</span><span class="n">Headers</span><span class="p">.</span><span class="n">Accept</span><span class="p">.</span><span class="nf">ParseAdd</span><span class="p">(</span><span class="s">"application/json"</span><span class="p">);</span>
<span class="n">forward</span><span class="p">.</span><span class="n">Headers</span><span class="p">.</span><span class="n">Accept</span><span class="p">.</span><span class="nf">ParseAdd</span><span class="p">(</span><span class="s">"text/event-stream"</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Responses framed as SSE.</strong> The body comes back as <code class="language-plaintext highlighter-rouge">event: message</code> / <code class="language-plaintext highlighter-rouge">data: {...}</code> lines, which isn’t parseable JSON-RPC. The script unwraps the frames and returns the message as <code class="language-plaintext highlighter-rouge">application/json</code>. It scans every frame and prefers the one carrying <code class="language-plaintext highlighter-rouge">result</code> or <code class="language-plaintext highlighter-rouge">error</code>, since a single response can include progress events ahead of the real answer:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">lastPayload</span> <span class="p">=</span> <span class="n">payload</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">parsed</span><span class="p">[</span><span class="s">"result"</span><span class="p">]</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">||</span> <span class="n">parsed</span><span class="p">[</span><span class="s">"error"</span><span class="p">]</span> <span class="p">!=</span> <span class="k">null</span><span class="p">)</span>
    <span class="n">bestPayload</span> <span class="p">=</span> <span class="n">payload</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>202 with an empty body on notifications.</strong> Correct per spec, and Copilot Studio reports it as an invalid JSON-RPC response. Empty successes get a valid envelope instead:</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="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">responseBody</span><span class="p">))</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">IsSuccessStatusCode</span><span class="p">)</span>
        <span class="k">return</span> <span class="nf">CreateJsonRpcSuccessResponse</span><span class="p">(</span><span class="n">requestId</span><span class="p">,</span> <span class="k">new</span> <span class="nf">JObject</span><span class="p">());</span>
    <span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="schema-normalization-on-toolslist">Schema normalization on tools/list</h2>

<p>The tool schemas are valid JSON Schema that Power Platform won’t take. <code class="language-plaintext highlighter-rouge">NormalizeToolsList</code> rewrites four things:</p>

<ul>
  <li>Nullable union types (<code class="language-plaintext highlighter-rouge">"type": ["integer","null"]</code> on <code class="language-plaintext highlighter-rouge">skip</code>) collapse to a single scalar type</li>
  <li><code class="language-plaintext highlighter-rouge">"default": null</code> entries are dropped</li>
  <li>The non-standard <code class="language-plaintext highlighter-rouge">execution</code> vendor key is removed from each tool object</li>
  <li>Non-boolean <code class="language-plaintext highlighter-rouge">exclusiveMinimum</code> and <code class="language-plaintext highlighter-rouge">exclusiveMaximum</code> values are coerced</li>
</ul>

<p>Descriptions pass through untouched. The upstream OData filter guidance in those descriptions is what lets an agent build a working filter, so rewriting it would cost more than the schema fixes save.</p>

<p>If parsing fails at any point, the original content is returned rather than a broken rewrite:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">catch</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">content</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-rest-operations-exist-because-mcp-has-a-ceiling">The REST operations exist because MCP has a ceiling</h2>

<p>The MCP tools cap at 50 items and truncate descriptions to fit a context window. That’s right for an agent and wrong for a flow that needs to page 1,000 Azure retirements into a table.</p>

<p>The MCP tools wrap a public OData v4 API on the same host. Its service container is named <code class="language-plaintext highlighter-rouge">ReleaseCommunicationsApi</code> — the same string the MCP server reports as <code class="language-plaintext highlighter-rouge">serverInfo.name</code>. The connector exposes the entity sets directly:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET /releasecommunications/api/v2/M365?$filter=products/any(p: p eq 'Microsoft Teams')&amp;$top=50
GET /releasecommunications/api/v2/Azure?$filter=tags/any(t: t eq 'Retirements')&amp;$count=true
GET /releasecommunications/api/v2/Azure?$filter=availabilities/any(a: a/ring eq 'Retirement' and a/year eq 2026)
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">$filter</code>, <code class="language-plaintext highlighter-rouge">$search</code>, <code class="language-plaintext highlighter-rouge">$orderby</code>, <code class="language-plaintext highlighter-rouge">$select</code>, <code class="language-plaintext highlighter-rouge">$top</code>, <code class="language-plaintext highlighter-rouge">$skip</code>, and <code class="language-plaintext highlighter-rouge">$count</code> all work, including <code class="language-plaintext highlighter-rouge">any()</code> lambdas over collections and the nested <code class="language-plaintext highlighter-rouge">availabilities</code> complex type.</p>

<p>Entity key access such as <code class="language-plaintext highlighter-rouge">M365(569217)</code> returns 404. Fetch one post with <code class="language-plaintext highlighter-rouge">$filter=id eq 569217</code>, or the v1 path <code class="language-plaintext highlighter-rouge">/api/v1/m365/569217</code>.</p>

<p>The OData endpoints aren’t on Microsoft Learn. They’re public, anonymous, and back the published roadmap and updates sites, but they carry no compatibility guarantee. The MCP endpoint is the documented surface — prefer it for agents and treat REST as convenience for flows.</p>

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

<p>Enter OData expressions unescaped. The connector handles URL encoding.</p>

<table>
  <thead>
    <tr>
      <th>Goal</th>
      <th>Filter</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Teams features still in development</td>
      <td><code class="language-plaintext highlighter-rouge">products/any(p: p eq 'Microsoft Teams') and status eq 'In development'</code></td>
    </tr>
    <tr>
      <td>Features reaching GA in a month</td>
      <td><code class="language-plaintext highlighter-rouge">generalAvailabilityDate eq '2026-02'</code></td>
    </tr>
    <tr>
      <td>Posts published on or after a date</td>
      <td><code class="language-plaintext highlighter-rouge">created ge 2026-02-01T00:00:00Z</code></td>
    </tr>
    <tr>
      <td>GCC High launched features</td>
      <td><code class="language-plaintext highlighter-rouge">cloudInstances/any(ci: ci eq 'GCC High') and status eq 'Launched'</code></td>
    </tr>
  </tbody>
</table>

<p>Set <strong>Include Count</strong> to <code class="language-plaintext highlighter-rouge">true</code> and read <code class="language-plaintext highlighter-rouge">@odata.count</code> to size a paging loop, then step <strong>Skip</strong> by your <strong>Top</strong> value. Use <strong>Select</strong> — descriptions are full HTML and will bloat a flow run.</p>

<p>For change detection, run a <strong>Recurrence</strong> trigger against an RSS operation, or query with <code class="language-plaintext highlighter-rouge">created ge</code> using the previous run’s timestamp.</p>

<h2 id="in-copilot-studio">In Copilot Studio</h2>

<p>Add the connector under <strong>Tools</strong> &gt; <strong>Add a tool</strong> &gt; <strong>Model Context Protocol</strong>, pick the connection, and add the tools.</p>

<p>The server covers two catalogs, so name the source in the prompt:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which Microsoft Teams features on the Microsoft 365 Roadmap are releasing in June?
What is the status of Feature ID 526798 on the Microsoft 365 Roadmap?
Show all Azure retirements scheduled for this year.
Which Azure Databricks features reached general availability in February?
</code></pre></div></div>

<p>Roadmap and update data is the kind of thing a model will answer from memory, confidently and a year out of date. If that happens, name the tools in the agent instructions:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code>When the user asks about Microsoft 365 Roadmap features, Azure service updates, release
timing, or retirements, call get_recent_m365_roadmaps, get_m365_roadmap_by_id,
get_recent_azure_updates, or get_azure_update_by_id before answering.
</code></pre></div></div>

<p>Set <code class="language-plaintext highlighter-rouge">include_facets</code> to <code class="language-plaintext highlighter-rouge">true</code> on either list tool to discover valid filter values before filtering.</p>

<h2 id="setup">Setup</h2>

<p>No connection parameters. No app registration.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">paconn</span><span class="w"> </span><span class="nx">login</span><span class="w">
</span><span class="n">paconn</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--api-def</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="nt">--api-prop</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="nt">--script</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Test <code class="language-plaintext highlighter-rouge">InvokeMCP</code> with an initialize payload. A successful response reports <code class="language-plaintext highlighter-rouge">serverInfo.name</code> as <code class="language-plaintext highlighter-rouge">ReleaseCommunicationsApi</code>.</p>

<p>The connector is anonymous by design, and the caller’s <code class="language-plaintext highlighter-rouge">Authorization</code> header is deliberately not forwarded to the public endpoint.</p>

<p>Application Insights logging is in the script and off until you replace the placeholder instrumentation key. It emits <code class="language-plaintext highlighter-rouge">McpRequestCompleted</code> and <code class="language-plaintext highlighter-rouge">RequestError</code> with the MCP method, tool name, correlation ID, status code, and duration. Telemetry failures are swallowed so they never affect a call.</p>

<p>If you’re not on Power Platform, skip the connector and point your client at the server:</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">"servers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"MRC-MCP-Server"</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">"http"</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://www.microsoft.com/releasecommunications/mcp"</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="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Microsoft%20Release%20Communications%20MCP">Source code</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/admin/manage/mrc-mcp">Get started with the Microsoft Release Communications MCP Server</a></li>
  <li><a href="https://www.microsoft.com/microsoft-365/roadmap">Microsoft 365 Roadmap</a></li>
  <li><a href="https://azure.microsoft.com/updates">Azure Updates</a></li>
  <li><a href="https://learn.microsoft.com/legal/microsoft-apis/terms-of-use">Microsoft API Terms of Use</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="Custom Connectors" /><category term="Power Automate" /><category term="Microsoft 365 Roadmap" /><category term="Azure Updates" /><category term="OData" /><category term="Integration" /><summary type="html"><![CDATA[A hybrid Power Platform custom connector for the Microsoft Release Communications MCP Server. The MCP endpoint serves Copilot Studio agents, and OData plus RSS operations serve Power Automate and Power Apps.]]></summary></entry><entry><title type="html">Cap Copilot Studio credit spend per environment with thresholds</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-13-power-platform-admin-resource-thresholds.html" rel="alternate" type="text/html" title="Cap Copilot Studio credit spend per environment with thresholds" /><published>2026-08-13T14:00:00+00:00</published><updated>2026-08-13T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/power-platform-admin-resource-thresholds</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-13-power-platform-admin-resource-thresholds.html"><![CDATA[<p>Turning off <strong>Draw from Tenant Pool</strong> stops an environment from spending shared capacity. It doesn’t cap what that environment spends on its own allocation, and it does nothing for an environment billing overage to pay-as-you-go at $0.01 per credit. A resource threshold is the limit itself: how much of an entitlement a resource may consume, at what percentage to send a notification, and whether to stop when the number is passed.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Platform%20Admin">Power Platform Admin v1.3</a> adds two tools for that surface. This one is <a href="https://learn.microsoft.com/rest/api/power-platform/licensing/resource-threshold">in the published REST reference</a>, which is a change from the <a href="/power%20platform/custom%20connectors/mcp/2026-08-05-power-platform-admin-tenant-pool-draw.html">v1.1 tenant pool tools</a> that call an undocumented route. The <a href="/power%20platform/custom%20connectors/mcp/2026-08-06-power-platform-admin-agent-inventory.html">v1.2 post</a> covers agent inventory, 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-13">What’s new in 1.3</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>v1.2</th>
      <th>v1.3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MCP tools</td>
      <td>15</td>
      <td>17</td>
    </tr>
    <tr>
      <td>Power Automate actions</td>
      <td>7 typed operations</td>
      <td>9 typed operations</td>
    </tr>
    <tr>
      <td>Licensing routes</td>
      <td>Undocumented <code class="language-plaintext highlighter-rouge">licensing/allocations</code></td>
      <td>Adds documented <code class="language-plaintext highlighter-rouge">licensing/.../threshold</code></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_resource_thresholds</code></td>
      <td><strong>List Resource Thresholds</strong></td>
      <td>Every threshold on an entitlement, with limit, consumption, and stop flags</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_upsert_resource_threshold</code></td>
      <td><strong>Upsert Resource Threshold</strong></td>
      <td>Create or update the threshold for one environment resource</td>
    </tr>
  </tbody>
</table>

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

<p>The read is entitlement-scoped and returns every environment that has a threshold. The write is environment-scoped and targets one resource.</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET  /licensing/entitlements/{entitlementId}/resourceThresholds?api-version=2024-10-01
PUT  /licensing/environments/{environmentId}/entitlements/{entitlementId}/resources/{resourceId}/threshold?api-version=2024-10-01
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">entitlementId</code> is what the resource consumes — <code class="language-plaintext highlighter-rouge">MCSMessages</code> or <code class="language-plaintext highlighter-rouge">MCSSessions</code> for Copilot Studio. Those IDs predate the rename to Copilot Credits, and the API never followed it.</p>

<p><code class="language-plaintext highlighter-rouge">resourceId</code> has no discovery route of its own, so read before you write:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What resource thresholds are set on the MCSMessages entitlement?
</code></pre></div></div>

<p>Both tools live on <code class="language-plaintext highlighter-rouge">api.powerplatform.com</code>. The tenant pool tools are the ones that need the tenant-routed host.</p>

<pre><code class="language-mermaid">flowchart TD
    A[Copilot Studio agent&lt;br/&gt;or Power Automate flow] --&gt; B[script.csx]
    B --&gt; C["api.powerplatform.com&lt;br/&gt;environments, settings, governance, inventory, thresholds"]
    B --&gt; D["first30hex.last2hex.tenant.api.powerplatform.com&lt;br/&gt;licensing allocations"]
</code></pre>

<h2 id="put-replaces-the-whole-document">PUT replaces the whole document</h2>

<p>The verb is <code class="language-plaintext highlighter-rouge">PUT</code> and the body is the complete threshold. Send <code class="language-plaintext highlighter-rouge">{ "limit": 50000 }</code> against a threshold that already notifies at 80% and stops over capacity, and both of those come back unset.</p>

<p>So the connector reads the current threshold, seeds the payload from it, and overlays only the fields you supplied:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">existing</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">TryFindResourceThreshold</span><span class="p">(</span><span class="n">entitlementId</span><span class="p">,</span> <span class="n">envId</span><span class="p">,</span> <span class="n">resourceId</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">payload</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">JObject</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span><span class="n">existing</span> <span class="p">!=</span> <span class="k">null</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">field</span> <span class="k">in</span> <span class="n">THRESHOLD_WRITABLE_FIELDS</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">current</span> <span class="p">=</span> <span class="n">existing</span><span class="p">[</span><span class="n">field</span><span class="p">];</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">current</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">&amp;&amp;</span> <span class="n">current</span><span class="p">.</span><span class="n">Type</span> <span class="p">!=</span> <span class="n">JTokenType</span><span class="p">.</span><span class="n">Null</span><span class="p">)</span>
            <span class="n">payload</span><span class="p">[</span><span class="n">field</span><span class="p">]</span> <span class="p">=</span> <span class="nf">NormalizeThresholdSeed</span><span class="p">(</span><span class="n">field</span><span class="p">,</span> <span class="n">current</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kt">var</span> <span class="n">applied</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">JArray</span><span class="p">();</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">field</span> <span class="k">in</span> <span class="n">THRESHOLD_WRITABLE_FIELDS</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">supplied</span> <span class="p">=</span> <span class="n">arguments</span><span class="p">[</span><span class="n">field</span><span class="p">];</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">supplied</span> <span class="p">==</span> <span class="k">null</span> <span class="p">||</span> <span class="n">supplied</span><span class="p">.</span><span class="n">Type</span> <span class="p">==</span> <span class="n">JTokenType</span><span class="p">.</span><span class="n">Null</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>

    <span class="n">payload</span><span class="p">[</span><span class="n">field</span><span class="p">]</span> <span class="p">=</span> <span class="nf">CoerceThresholdValue</span><span class="p">(</span><span class="n">field</span><span class="p">,</span> <span class="n">supplied</span><span class="p">);</span>
    <span class="n">applied</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">field</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The response reports <code class="language-plaintext highlighter-rouge">mergedWithExisting</code> and an <code class="language-plaintext highlighter-rouge">appliedFields</code> array, so a flow can log what actually changed rather than what was sent.</p>

<p>There’s no ETag on the route. A concurrent edit from the admin center between the read and the write is still lost. Don’t run this on a fan-out loop over 40 environments while someone is in PPAC.</p>

<h2 id="the-read-model-and-the-write-model-disagree">The read model and the write model disagree</h2>

<p><code class="language-plaintext highlighter-rouge">limit</code> comes back as <code class="language-plaintext highlighter-rouge">number (double)</code> on <code class="language-plaintext highlighter-rouge">ResourceThresholdModel</code> and goes in as <code class="language-plaintext highlighter-rouge">integer (int32)</code> on <code class="language-plaintext highlighter-rouge">ResourceThresholdRequestModel</code>. Both are documented that way. Read a threshold of 50000, echo it back untouched as part of a merge, and the service can reject <code class="language-plaintext highlighter-rouge">50000.0</code> on model binding — a failure caused entirely by preserving a value nobody asked to change.</p>

<p>Seeded values get rounded to int on the way back in:</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="n">JToken</span> <span class="nf">NormalizeThresholdSeed</span><span class="p">(</span><span class="kt">string</span> <span class="n">field</span><span class="p">,</span> <span class="n">JToken</span> <span class="k">value</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">field</span> <span class="p">==</span> <span class="s">"limit"</span> <span class="p">||</span> <span class="n">field</span> <span class="p">==</span> <span class="s">"notificationThreshold"</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">double</span> <span class="n">number</span><span class="p">;</span>
        <span class="k">if</span> <span class="p">(</span><span class="nf">TryReadDouble</span><span class="p">(</span><span class="k">value</span><span class="p">,</span> <span class="k">out</span> <span class="n">number</span><span class="p">)</span> <span class="p">&amp;&amp;</span> <span class="n">number</span> <span class="p">&gt;=</span> <span class="kt">int</span><span class="p">.</span><span class="n">MinValue</span> <span class="p">&amp;&amp;</span> <span class="n">number</span> <span class="p">&lt;=</span> <span class="kt">int</span><span class="p">.</span><span class="n">MaxValue</span><span class="p">)</span>
            <span class="k">return</span> <span class="p">(</span><span class="kt">int</span><span class="p">)</span><span class="n">Math</span><span class="p">.</span><span class="nf">Round</span><span class="p">(</span><span class="n">number</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="k">value</span><span class="p">.</span><span class="nf">DeepClone</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">resourceConsumption</code> stays a double. It’s a double on both models.</p>

<h2 id="a-failed-read-still-lets-you-write">A failed read still lets you write</h2>

<p><code class="language-plaintext highlighter-rouge">TryFindResourceThreshold</code> swallows its exception and returns null:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><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="c1">// A caller that can write but not read must still be able to upsert,</span>
    <span class="c1">// so a failed read degrades to a create rather than failing the call.</span>
    <span class="k">this</span><span class="p">.</span><span class="n">Context</span><span class="p">.</span><span class="n">Logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="s">$"Could not read existing resource thresholds to merge: </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="s">"</span><span class="p">);</span>
    <span class="k">return</span> <span class="k">null</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>An admin whose role grants the write but not the entitlement-wide read would otherwise be blocked from setting a limit by a call they never asked for. The degraded path is a create, and the response says so: <code class="language-plaintext highlighter-rouge">mergedWithExisting</code> is <code class="language-plaintext highlighter-rouge">false</code> and the message notes that unspecified fields were left unset.</p>

<p>Read the message. It’s the difference between a limit you added to an existing threshold and one that quietly replaced it.</p>

<h2 id="validation-before-the-round-trip">Validation before the round trip</h2>

<p><code class="language-plaintext highlighter-rouge">notificationThreshold</code> is a percentage. The API types it as int32 and stops there, so <code class="language-plaintext highlighter-rouge">notificationThreshold: 8000</code> for “80%” is a valid int32 that means nothing.</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">field</span> <span class="p">==</span> <span class="s">"notificationThreshold"</span> <span class="p">&amp;&amp;</span> <span class="n">number</span> <span class="p">&gt;</span> <span class="m">100</span><span class="p">)</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">ArgumentException</span><span class="p">(</span><span class="s">"notificationThreshold is a percentage and must be between 0 and 100."</span><span class="p">);</span>
</code></pre></div></div>

<p>An empty write is also rejected locally. A <code class="language-plaintext highlighter-rouge">PUT</code> with three path parameters and no body fields is a request to blank the document:</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">applied</span><span class="p">.</span><span class="n">Count</span> <span class="p">==</span> <span class="m">0</span><span class="p">)</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">ArgumentException</span><span class="p">(</span>
        <span class="s">"Supply at least one threshold field to set: limit, notificationThreshold, notifyIfOverCapacity, resourceConsumption, stopIfOverCapacity, or stopResource."</span><span class="p">);</span>
</code></pre></div></div>

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

<p>Both actions render with the same environment picker as the rest of the connector — display names at design time, no GUIDs to paste. <code class="language-plaintext highlighter-rouge">entitlementId</code> and <code class="language-plaintext highlighter-rouge">resourceId</code> are free text.</p>

<p><strong>List Resource Thresholds</strong> returns <code class="language-plaintext highlighter-rouge">entitlementId</code>, <code class="language-plaintext highlighter-rouge">environmentId</code>, <code class="language-plaintext highlighter-rouge">thresholdCount</code>, and a <code class="language-plaintext highlighter-rouge">thresholds</code> array. Apply-to-each over <code class="language-plaintext highlighter-rouge">thresholds</code>. Passing <code class="language-plaintext highlighter-rouge">environmentId</code> filters the array; leaving it blank returns every environment on the entitlement.</p>

<p><strong>Upsert Resource Threshold</strong> takes the three IDs on the query string and everything else in the body. Omit a property to keep its current value. Sending <code class="language-plaintext highlighter-rouge">null</code> is treated the same as omitting it, so a flow that builds the body from optional inputs won’t clear a field it never populated.</p>

<p>A weekly consumption report is the read on its own: list <code class="language-plaintext highlighter-rouge">MCSMessages</code> thresholds, filter to rows where <code class="language-plaintext highlighter-rouge">resourceConsumption</code> is past <code class="language-plaintext highlighter-rouge">notificationThreshold</code> percent of <code class="language-plaintext highlighter-rouge">limit</code>, and mail the table.</p>

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

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What resource thresholds are set on the MCSMessages entitlement?
Which environments are closest to their Copilot Studio message limit?
Cap Copilot Studio messages at 50,000 for this environment and notify me at 80%
Stop the resource once it goes over its message limit
Show me every threshold with stopIfOverCapacity turned off
</code></pre></div></div>

<p>With <a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-generative-actions">generative orchestration</a> enabled, the agent can chain the threshold read into <code class="language-plaintext highlighter-rouge">admin_list_agents</code> and answer which agents sit in the environment that’s about to blow its limit.</p>

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

<p>No new scope. The threshold routes sit under <code class="language-plaintext highlighter-rouge">licensing</code>, so the <code class="language-plaintext highlighter-rouge">Licensing.Allocations.*</code> scopes already on the app registration plus a tenant admin role are what the connector uses. There’s no threshold-specific permission published — verify with <code class="language-plaintext highlighter-rouge">admin_list_resource_thresholds</code> before relying on the 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. No scopes changed.</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/resource-threshold">Resource Threshold REST reference</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/resource-threshold/upsert-resource-threshold">Upsert Resource Threshold</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/manage-copilot-studio-messages-capacity">Manage Copilot Studio credits and capacity</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="Copilot Credits" /><category term="Power Automate" /><summary type="html"><![CDATA[Version 1.3 of the Power Platform Admin custom MCP connector adds two resource threshold tools that read and set consumption limits, notification percentages, and stop flags on a licensing entitlement. Unlike the tenant pool tools, this route is documented.]]></summary></entry><entry><title type="html">Update custom connector OAuth identity providers with PAC CLI</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-08-07-update-custom-connector-oauth-identity-providers-pac-cli.html" rel="alternate" type="text/html" title="Update custom connector OAuth identity providers with PAC CLI" /><published>2026-08-07T17:00:00+00:00</published><updated>2026-08-07T17:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/update-custom-connector-oauth-identity-providers-pac-cli</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-08-07-update-custom-connector-oauth-identity-providers-pac-cli.html"><![CDATA[<p>Use Power Platform CLI (<code class="language-plaintext highlighter-rouge">pac</code>) to configure an OAuth identity provider that isn’t available in the Power Platform custom connector designer. The process downloads the connector’s API properties, updates <code class="language-plaintext highlighter-rouge">identityProvider</code>, and uploads the modified file.</p>

<h2 id="oauth-identity-provider-configuration">OAuth identity provider configuration</h2>

<p>The connector’s OAuth configuration is stored in <code class="language-plaintext highlighter-rouge">properties.connectionParameters.token.oAuthSettings</code> 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="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">"connectionParameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"token"</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">"oauthSetting"</span><span class="p">,</span><span class="w">
        </span><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">"oauth2"</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>The <code class="language-plaintext highlighter-rouge">identityProvider</code> value selects the runtime OAuth implementation. Connector artifacts use identifiers including:</p>

<table>
  <thead>
    <tr>
      <th>Identifier</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aad</code></td>
      <td>Microsoft Entra ID OAuth</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">oauth2</code></td>
      <td>Generic OAuth 2.0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">oauth2pkce</code></td>
      <td>Generic OAuth 2.0 with PKCE</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">oauth2generic</code></td>
      <td>Template-based generic OAuth configuration</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DocuSign</code></td>
      <td>DocuSign-specific OAuth behavior</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SalesforceV2</code></td>
      <td>Salesforce OAuth</td>
    </tr>
  </tbody>
</table>

<p>Use the identifier and connection parameters from a tested connector artifact or provided by Microsoft support for the target provider.</p>

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

<ul>
  <li>Power Platform CLI</li>
  <li>An authenticated PAC CLI profile</li>
  <li>A solution-aware custom connector</li>
  <li>The connector’s Dataverse row ID</li>
  <li>Permission to update the connector</li>
  <li>The provider’s OAuth client ID and client secret</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">pac connector</code> commands operate on solution-aware connectors stored in Dataverse. Use <code class="language-plaintext highlighter-rouge">paconn</code> for a custom connector that isn’t solution-aware.</p>

<h2 id="1-authenticate-and-get-the-connector-id">1. Authenticate and get the connector ID</h2>

<p>Authenticate to the target environment:</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="s2">"https://contoso.crm.dynamics.com"</span><span class="w">
</span></code></pre></div></div>

<p>List the solution-aware connectors:</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">list</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--environment</span><span class="w"> </span><span class="s2">"https://contoso.crm.dynamics.com"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--json</span><span class="w">
</span></code></pre></div></div>

<h2 id="2-download-the-connector">2. Download the connector</h2>

<p>Use the connector ID returned by <code class="language-plaintext highlighter-rouge">pac connector list</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="se">`
</span><span class="w">  </span><span class="nt">--connector-id</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--environment</span><span class="w"> </span><span class="s2">"https://contoso.crm.dynamics.com"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="s2">".\connector"</span><span class="w">
</span></code></pre></div></div>

<p>Back up the API properties file:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Copy-Item</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">".\connector\apiProperties.json"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-Destination</span><span class="w"> </span><span class="s2">".\connector\apiProperties.backup.json"</span><span class="w">
</span></code></pre></div></div>

<h2 id="3-update-apipropertiesjson">3. Update apiProperties.json</h2>

<p>Open <code class="language-plaintext highlighter-rouge">connector\apiProperties.json</code>. Find:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>properties.connectionParameters.token.oAuthSettings
</code></pre></div></div>

<p>Change <code class="language-plaintext highlighter-rouge">identityProvider</code> and retain the connection parameters required by the provider. The following example configures <code class="language-plaintext highlighter-rouge">oauth2pkce</code>, which the designer doesn’t list:</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">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"connectionParameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"token"</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">"oauthSetting"</span><span class="p">,</span><span class="w">
        </span><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">"oauth2pkce"</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">"clientSecret"</span><span class="p">:</span><span class="w"> </span><span class="s2">"YOUR_CLIENT_SECRET"</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_REQUIRED_SCOPES"</span><span class="w">
          </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">"GlobalPerConnector"</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">"https://global.consent.azure-apim.net/redirect/UNIQUE_IDENTIFIER_FOR_THIS_ENVIRONMENT"</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">"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://api.contoso.com/oauth2/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://api.contoso.com/oauth2/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://api.contoso.com/oauth2/token"</span><span class="w">
            </span><span class="p">}</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">"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="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>The provider accepts these parameters:</p>

<table>
  <thead>
    <tr>
      <th>Parameter</th>
      <th>Required</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">clientId</code></td>
      <td>Yes</td>
      <td>Client ID registered with the authorization server</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">clientSecret</code></td>
      <td>No</td>
      <td>Client secret, sent on the token and refresh requests</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">authorizationUrl</code></td>
      <td>Yes</td>
      <td>Authorization endpoint</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tokenUrl</code></td>
      <td>Yes</td>
      <td>Token endpoint</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">refreshUrl</code></td>
      <td>Yes</td>
      <td>Refresh endpoint, often the same as the token endpoint</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">idpHint</code></td>
      <td>No</td>
      <td>Sends <code class="language-plaintext highlighter-rouge">idp_hint</code> on the authorization request</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">audience</code></td>
      <td>No</td>
      <td>Sends <code class="language-plaintext highlighter-rouge">audience</code> on the authorization request</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">clientSecret</code> is optional, so a public client can authenticate with PKCE alone. Set <code class="language-plaintext highlighter-rouge">clientId</code>, <code class="language-plaintext highlighter-rouge">authorizationUrl</code>, <code class="language-plaintext highlighter-rouge">tokenUrl</code>, and <code class="language-plaintext highlighter-rouge">refreshUrl</code> or the connection fails validation.</p>

<p>The provider generates the code verifier for you. It appends <code class="language-plaintext highlighter-rouge">code_challenge</code> and <code class="language-plaintext highlighter-rouge">code_challenge_method=S256</code> to the authorization request and sends <code class="language-plaintext highlighter-rouge">code_verifier</code> on the token exchange, so don’t add those values yourself. Entries in <code class="language-plaintext highlighter-rouge">scopes</code> are joined with a space.</p>

<p>Requests the provider builds:</p>

<ul>
  <li>Authorization: <code class="language-plaintext highlighter-rouge">client_id</code>, <code class="language-plaintext highlighter-rouge">response_type=code</code>, <code class="language-plaintext highlighter-rouge">redirect_uri</code>, <code class="language-plaintext highlighter-rouge">scope</code>, <code class="language-plaintext highlighter-rouge">state</code>, <code class="language-plaintext highlighter-rouge">code_challenge</code>, <code class="language-plaintext highlighter-rouge">code_challenge_method</code>, <code class="language-plaintext highlighter-rouge">idp_hint</code>, <code class="language-plaintext highlighter-rouge">audience</code></li>
  <li>Token: <code class="language-plaintext highlighter-rouge">code</code>, <code class="language-plaintext highlighter-rouge">grant_type=authorization_code</code>, <code class="language-plaintext highlighter-rouge">redirect_uri</code>, <code class="language-plaintext highlighter-rouge">client_id</code>, <code class="language-plaintext highlighter-rouge">client_secret</code>, <code class="language-plaintext highlighter-rouge">code_verifier</code></li>
  <li>Refresh: <code class="language-plaintext highlighter-rouge">refresh_token</code>, <code class="language-plaintext highlighter-rouge">grant_type=refresh_token</code>, <code class="language-plaintext highlighter-rouge">client_id</code>, <code class="language-plaintext highlighter-rouge">client_secret</code></li>
</ul>

<p>Both the token and refresh requests send the client secret in the body, so the authorization server has to accept <code class="language-plaintext highlighter-rouge">client_secret_post</code>. A server that requires HTTP Basic client authentication won’t work with this provider.</p>

<p>Retain the environment-specific <code class="language-plaintext highlighter-rouge">redirectUrl</code> from the downloaded connector and register the same URL in the provider’s OAuth application. Store the client secret outside source control and inject it during deployment.</p>

<p>The provider has no token introspection, so Power Platform learns the token lifetime only from an <code class="language-plaintext highlighter-rouge">expires_in</code> value in the token response. If the authorization server returns an opaque token without <code class="language-plaintext highlighter-rouge">expires_in</code>, the connection goes stale instead of refreshing. The connection display name also comes from the <code class="language-plaintext highlighter-rouge">unique_name</code> claim in an OpenID token, so an opaque access token leaves the name blank.</p>

<h2 id="4-upload-the-updated-api-properties">4. Upload the updated API properties</h2>

<p>Update only the API properties 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">--connector-id</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--environment</span><span class="w"> </span><span class="s2">"https://contoso.crm.dynamics.com"</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">".\connector\apiProperties.json"</span><span class="w">
</span></code></pre></div></div>

<p>If the OpenAPI definition also changed, include it in the update:</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">--connector-id</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--environment</span><span class="w"> </span><span class="s2">"https://contoso.crm.dynamics.com"</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">".\connector\apiProperties.json"</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">".\connector\apiDefinition.swagger.json"</span><span class="w">
</span></code></pre></div></div>

<h2 id="5-verify-the-identity-provider">5. Verify the identity provider</h2>

<p>Download the connector again to a separate directory:</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">--connector-id</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--environment</span><span class="w"> </span><span class="s2">"https://contoso.crm.dynamics.com"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="s2">".\connector-verify"</span><span class="w">
</span></code></pre></div></div>

<p>Confirm that <code class="language-plaintext highlighter-rouge">connector-verify\apiProperties.json</code> contains the expected provider.</p>

<p>Test the connection:</p>

<ol>
  <li>Delete any test connection created with the old OAuth configuration</li>
  <li>Create a new connection for the custom connector</li>
  <li>Complete the provider’s sign-in and consent flow</li>
  <li>Run a connector action</li>
  <li>Test again after the access token expires to confirm refresh works</li>
</ol>

<h2 id="6-roll-back-the-update">6. Roll back the update</h2>

<p>If sign-in or refresh fails, restore the downloaded backup:</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">--connector-id</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--environment</span><span class="w"> </span><span class="s2">"https://contoso.crm.dynamics.com"</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">".\connector\apiProperties.backup.json"</span><span class="w">
</span></code></pre></div></div>

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

<ul>
  <li><a href="https://learn.microsoft.com/power-platform/developer/cli/reference/connector">PAC CLI connector reference</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/paconn-cli">Create a custom connector with the CLI</a></li>
  <li><a href="https://github.com/microsoft/PowerPlatformConnectors">Microsoft Power Platform Connectors samples</a></li>
  <li><a href="https://github.com/microsoft/PowerPlatformConnectors/blob/dev/certified-connectors/DocuSignDemo/apiProperties.json">DocuSign identity provider sample</a></li>
  <li><a href="https://github.com/microsoft/PowerPlatformConnectors/blob/dev/certified-connectors/GetAccept/apiProperties.json">Template-based OAuth sample</a></li>
  <li><a href="https://datatracker.ietf.org/doc/html/rfc7636">PKCE (RFC 7636)</a></li>
  <li><a href="/power%20platform/custom%20connectors/2026/06/10/power-platform-connector-cli-commands-reference.html">Power Platform connector CLI commands reference</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="PAC CLI" /><category term="OAuth" /><category term="PKCE" /><category term="Custom Connectors" /><category term="Power Automate" /><category term="Copilot Studio" /><summary type="html"><![CDATA[Use Power Platform CLI to set an OAuth identity provider in apiProperties.json when the Power Platform custom connector UI doesn't offer the provider you need.]]></summary></entry><entry><title type="html">Find every GitHub Copilot harness agent in your tenant with Power Platform Admin v1.2</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-06-power-platform-admin-agent-inventory.html" rel="alternate" type="text/html" title="Find every GitHub Copilot harness agent in your tenant with Power Platform Admin v1.2" /><published>2026-08-06T19:00:00+00:00</published><updated>2026-08-06T19:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/power-platform-admin-agent-inventory</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-06-power-platform-admin-agent-inventory.html"><![CDATA[<p>The GitHub Copilot harness <a href="https://techcommunity.microsoft.com/blog/copilot-studio-blog/more-powerful-agents-and-workflows-for-autonomous-business-processes-introducing/4542969">went generally available on August 3</a>. A light task on it costs 100 to 300 Copilot Credits. A complete four-action run on the Standard harness costs 20, and costs nothing at all when the caller holds a Microsoft 365 Copilot license.</p>

<p>Makers pick the harness. Nothing tells you which one they picked. The Power Platform admin center lists agents one environment at a time and doesn’t show harness at all, so the question “which of my agents run on the expensive one” has no screen behind it.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Platform%20Admin">Power Platform Admin v1.2</a> adds <code class="language-plaintext highlighter-rouge">admin_list_agents</code>, which walks the whole estate in one call. Every agent comes back with an <code class="language-plaintext highlighter-rouge">isCLIAgent</code> value, and that field is the harness. It also carries environment context, sharing counts, Entra identity, and a risk level you can trace back to the signals that produced it.</p>

<p>Yesterday’s <a href="/power%20platform/custom%20connectors/mcp/2026-08-05-power-platform-admin-tenant-pool-draw.html">v1.1 post</a> covers the tenant pool tools and the dual-mode rebuild. 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-12">What’s new in 1.2</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>v1.1</th>
      <th>v1.2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MCP tools</td>
      <td>14</td>
      <td>15</td>
    </tr>
    <tr>
      <td>Power Automate actions</td>
      <td>6 typed operations</td>
      <td>7 typed operations</td>
    </tr>
    <tr>
      <td>APIs called</td>
      <td>Environments, settings, governance, licensing</td>
      <td>Adds <code class="language-plaintext highlighter-rouge">resourcequery</code></td>
    </tr>
  </tbody>
</table>

<p>One tool, one action:</p>

<table>
  <thead>
    <tr>
      <th>Surface</th>
      <th>Name</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Copilot Studio</td>
      <td><code class="language-plaintext highlighter-rouge">admin_list_agents</code></td>
    </tr>
    <tr>
      <td>Power Automate</td>
      <td><strong>List Agents</strong> at <code class="language-plaintext highlighter-rouge">/admin/agents</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">environmentId</code> is optional on both. Leave it blank and you get the tenant.</p>

<h2 id="iscliagent-is-the-harness">isCLIAgent is the harness</h2>

<p>This is the field to read first.</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">isCLIAgent</code></th>
      <th>Harness</th>
      <th>What it costs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"true"</code></td>
      <td>GitHub Copilot</td>
      <td>100–500+ 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>Not reported</td>
      <td>Unresolved</td>
    </tr>
  </tbody>
</table>

<p>Cloning both harnesses to disk shows why the field is named the way it is. A GitHub Copilot harness agent projects <code class="language-plaintext highlighter-rouge">template: cliagent-1.0.0</code> and <code class="language-plaintext highlighter-rouge">recognizer.kind: CLICopilotRecognizer</code>, where a Standard harness agent projects <code class="language-plaintext highlighter-rouge">default-2.1.0</code> and <code class="language-plaintext highlighter-rouge">GenerativeAIRecognizer</code>. The <a href="/copilot%20studio/power%20platform/2026-08-06-copilot-studio-agents-as-code.html">agents-as-code post</a> has the full comparison.</p>

<p>That makes the inventory a cost report. At $0.01 per credit, an agent on the wrong harness for its workload is a four-figure annual difference at modest volume, and roughly $400,000 against $0 for an internal agent at 100,000 interactions a year. One tenant-wide read tells you where those agents are and who owns them.</p>

<p>Development is on top of runtime. Building, previewing, testing, and generating evaluations all consume credits on the GitHub Copilot harness, and Microsoft publishes no figure for any of them. A GitHub Copilot harness agent in a developer environment that has never been published is still spending.</p>

<h2 id="absent-isnt-false">Absent isn’t false</h2>

<p><code class="language-plaintext highlighter-rouge">isCLIAgent</code> is missing entirely on some agents. Coercing that to <code class="language-plaintext highlighter-rouge">false</code> would report an agent as Standard harness when the platform never said so, and Standard is the cheap answer — the direction you least want a governance report to guess in. The connector returns three states:</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="kt">string</span> <span class="nf">TriState</span><span class="p">(</span><span class="n">JToken</span> <span class="n">parent</span><span class="p">,</span> <span class="kt">string</span> <span class="n">propertyName</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">token</span> <span class="p">=</span> <span class="n">parent</span><span class="p">?[</span><span class="n">propertyName</span><span class="p">];</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">token</span> <span class="p">==</span> <span class="k">null</span> <span class="p">||</span> <span class="n">token</span><span class="p">.</span><span class="n">Type</span> <span class="p">==</span> <span class="n">JTokenType</span><span class="p">.</span><span class="n">Null</span><span class="p">)</span> <span class="k">return</span> <span class="s">"unknown"</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">token</span><span class="p">.</span><span class="n">Type</span> <span class="p">==</span> <span class="n">JTokenType</span><span class="p">.</span><span class="n">Boolean</span><span class="p">)</span> <span class="k">return</span> <span class="n">token</span><span class="p">.</span><span class="n">Value</span><span class="p">&lt;</span><span class="kt">bool</span><span class="p">&gt;()</span> <span class="p">?</span> <span class="s">"true"</span> <span class="p">:</span> <span class="s">"false"</span><span class="p">;</span>

    <span class="kt">bool</span> <span class="n">parsed</span><span class="p">;</span>
    <span class="k">return</span> <span class="kt">bool</span><span class="p">.</span><span class="nf">TryParse</span><span class="p">(</span><span class="n">token</span><span class="p">.</span><span class="nf">ToString</span><span class="p">(),</span> <span class="k">out</span> <span class="n">parsed</span><span class="p">)</span> <span class="p">?</span> <span class="p">(</span><span class="n">parsed</span> <span class="p">?</span> <span class="s">"true"</span> <span class="p">:</span> <span class="s">"false"</span><span class="p">)</span> <span class="p">:</span> <span class="s">"unknown"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In a flow, compare against the strings <code class="language-plaintext highlighter-rouge">'true'</code>, <code class="language-plaintext highlighter-rouge">'false'</code>, and <code class="language-plaintext highlighter-rouge">'unknown'</code>. A condition that tests it as a boolean never matches. Treat <code class="language-plaintext highlighter-rouge">'unknown'</code> as a row to investigate, not as Standard.</p>

<p>One caveat on all of it: <code class="language-plaintext highlighter-rouge">resourcequery</code> returns the resource provider’s raw property bag, and <code class="language-plaintext highlighter-rouge">isCLIAgent</code> is not in the published reference. It can change shape or disappear without notice. Use it for reporting and chargeback triage, not as a hard enforcement gate.</p>

<h2 id="one-query-three-tables">One query, three tables</h2>

<p>Agents live in <code class="language-plaintext highlighter-rouge">PowerPlatformResources</code> under type <code class="language-plaintext highlighter-rouge">microsoft.copilotstudio/agents</code>. The rows carry an <code class="language-plaintext highlighter-rouge">environmentId</code> and nothing else about the environment, so an inventory built on that alone gives you 400 GUIDs and no way to tell production from a developer sandbox.</p>

<p>The connector joins the environment and environment group rows in the same query:</p>

<pre><code class="language-mermaid">flowchart LR
    A["microsoft.copilotstudio/agents"] --&gt;|leftouter on environmentId| B["microsoft.powerplatform/environments&lt;br/&gt;name, region, type, isManaged"]
    B --&gt;|leftouter on environmentGroupId| C["microsoft.powerplatform/environmentgroups&lt;br/&gt;group name"]
    C --&gt; D[Projected agent + risk]
</code></pre>

<p>Both joins are <code class="language-plaintext highlighter-rouge">leftouter</code>, so an agent in an environment the caller can’t read still comes back with its own properties intact rather than vanishing from the count.</p>

<p>One detail that costs an afternoon if you miss it: every clause is a polymorphic document and <code class="language-plaintext highlighter-rouge">$type</code> has to serialize first. Build the clause with <code class="language-plaintext highlighter-rouge">$type</code> anywhere but the first key and the service rejects the whole query.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">new</span> <span class="n">JObject</span>
<span class="p">{</span>
    <span class="p">[</span><span class="s">"$type"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"where"</span><span class="p">,</span>
    <span class="p">[</span><span class="s">"FieldName"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"type"</span><span class="p">,</span>
    <span class="p">[</span><span class="s">"Operator"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"in~"</span><span class="p">,</span>
    <span class="p">[</span><span class="s">"Values"</span><span class="p">]</span> <span class="p">=</span> <span class="k">new</span> <span class="n">JArray</span> <span class="p">{</span> <span class="n">AGENT_RESOURCE_TYPE</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="skiptoken-doesnt-page">SkipToken doesn’t page</h2>

<p><code class="language-plaintext highlighter-rouge">resourcequery</code> returns a <code class="language-plaintext highlighter-rouge">SkipToken</code> in its response. Send it back and you get the same rows again. Verified against the live API, it never advances.</p>

<p><code class="language-plaintext highlighter-rouge">Skip</code> offsets work. The connector pages 1,000 rows at a time and stops at 10 pages:</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">rows</span><span class="p">.</span><span class="n">Count</span> <span class="p">&lt;</span> <span class="n">AGENT_PAGE_SIZE</span><span class="p">)</span> <span class="k">break</span><span class="p">;</span>

<span class="k">if</span> <span class="p">(</span><span class="n">pages</span> <span class="p">&gt;=</span> <span class="n">AGENT_MAX_PAGES</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">truncated</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span>
    <span class="k">break</span><span class="p">;</span>
<span class="p">}</span>

<span class="n">skip</span> <span class="p">+=</span> <span class="n">AGENT_PAGE_SIZE</span><span class="p">;</span>
</code></pre></div></div>

<p>A 10,000-agent ceiling sets <code class="language-plaintext highlighter-rouge">truncated: true</code> in the response rather than returning a short list that looks complete. Scope to an environment if you hit it.</p>

<p>Offset paging has a second failure mode that’s quieter. Sorting on <code class="language-plaintext highlighter-rouge">createdAt</code> alone isn’t a total order — agents provisioned from a template share a timestamp to the second — so the service is free to order ties differently between requests, and the skip window slides over rows that were never returned. The sort carries <code class="language-plaintext highlighter-rouge">name</code> as a unique tiebreaker:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="s">"FieldNamesAscDesc"</span><span class="p">]</span> <span class="p">=</span> <span class="k">new</span> <span class="n">JObject</span>
<span class="p">{</span>
    <span class="p">[</span><span class="s">"tostring(properties.createdAt)"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"desc"</span><span class="p">,</span>
    <span class="p">[</span><span class="s">"name"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"asc"</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Results are also de-duplicated by resource name, so a repeated row can’t inflate the count if the service reorders anyway.</p>

<h2 id="a-risk-score-you-can-argue-with">A risk score you can argue with</h2>

<p>Every agent gets a <code class="language-plaintext highlighter-rouge">riskLevel</code> of None, Low, Medium, or High. A score with no explanation is a number an admin has to take on faith, so each agent also carries the <code class="language-plaintext highlighter-rouge">riskSignals</code> that produced it.</p>

<table>
  <thead>
    <tr>
      <th>Signal</th>
      <th>Score</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quarantined</code></td>
      <td>+3</td>
      <td>The platform already flagged it</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sharedWithEntireTenant</code></td>
      <td>+2</td>
      <td>Everyone can run it</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">broadEditorAccess</code></td>
      <td>+2</td>
      <td>10 or more editor users</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cliAuthoredAndTenantWide</code></td>
      <td>+2</td>
      <td>GitHub Copilot harness, shared to everyone</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">missingEntraAgentIdPostMandate</code></td>
      <td>+2</td>
      <td>No Entra Agent ID on an agent created after July 2026</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">noEntraAgentId</code></td>
      <td>+1</td>
      <td>Same gap on an older agent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">createdOutsideMakerPortal</code></td>
      <td>+1</td>
      <td><code class="language-plaintext highlighter-rouge">isCLIAgent</code> is <code class="language-plaintext highlighter-rouge">true</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">defaultEnvironment</code></td>
      <td>+1</td>
      <td>Default environment</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">unmanagedEnvironment</code></td>
      <td>+1</td>
      <td>Non-default environments only</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tenantWideInNonProductionEnvironment</code></td>
      <td>+1</td>
      <td>Trial or developer, shared to all</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">neverPublished</code></td>
      <td>−1</td>
      <td>No runtime exposure</td>
    </tr>
  </tbody>
</table>

<p>Thresholds are 5 for High, 3 for Medium, 1 for Low. The score floors at 0.</p>

<p><code class="language-plaintext highlighter-rouge">cliAuthoredAndTenantWide</code> is the pairing to watch. A GitHub Copilot harness agent shared with the entire tenant is the most expensive shape available: the priciest per-task rate, multiplied by everyone in the company, with no license inclusion to absorb it.</p>

<p>Three of those choices are worth defending.</p>

<p><strong>Editor breadth counts double, viewer breadth doesn’t.</strong> Editors can rewrite instructions and swap tools. Ten people who can change what an agent does is a bigger exposure than ten people who can talk to it.</p>

<p><strong>The default environment is scored as itself, not as unmanaged.</strong> It can never be a Managed Environment. Scoring it as unmanaged would fire on nearly every agent in most tenants and flatten the distribution into noise.</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">defaultEnvironment</span><span class="p">)</span> <span class="p">{</span> <span class="n">score</span> <span class="p">+=</span> <span class="m">1</span><span class="p">;</span> <span class="n">signals</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="s">"defaultEnvironment"</span><span class="p">);</span> <span class="p">}</span>
<span class="k">else</span> <span class="k">if</span> <span class="p">(!</span><span class="n">managedEnvironment</span><span class="p">)</span> <span class="p">{</span> <span class="n">score</span> <span class="p">+=</span> <span class="m">1</span><span class="p">;</span> <span class="n">signals</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="s">"unmanagedEnvironment"</span><span class="p">);</span> <span class="p">}</span>
</code></pre></div></div>

<p><strong>One signal subtracts.</strong> An agent that was never published has no runtime exposure, so <code class="language-plaintext highlighter-rouge">neverPublished</code> takes a point back. A half-built experiment in a dev environment shouldn’t outrank a published agent shared with the company.</p>

<h2 id="the-entra-agent-id-gap">The Entra Agent ID gap</h2>

<p>Entra Agent IDs give each agent a directory identity that Conditional Access and access reviews can act on. Newly created agents get one; older agents don’t have one to backfill.</p>

<p>That makes a missing ID mean two different things, so the connector dates it:</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">DateTimeOffset</span> <span class="n">ENTRA_AGENT_ID_MANDATE</span> <span class="p">=</span>
    <span class="k">new</span> <span class="nf">DateTimeOffset</span><span class="p">(</span><span class="m">2026</span><span class="p">,</span> <span class="m">7</span><span class="p">,</span> <span class="m">1</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">TimeSpan</span><span class="p">.</span><span class="n">Zero</span><span class="p">);</span>
</code></pre></div></div>

<p>A gap on an agent created before that date is a backlog item worth +1. A gap on an agent created after it means something bypassed the path that assigns one, and scores +2.</p>

<h2 id="what-comes-back">What comes back</h2>

<p>The response is one object, not a paged list:</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">"agentCount"</span><span class="p">:</span><span class="w"> </span><span class="mi">412</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">"environmentId"</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">"agents"</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>Each agent carries display name, schema name, environment name, region, type, managed state, environment group, creator, creation date, last publish, owner, authentication mode, orchestration mode, model, quarantine state, <code class="language-plaintext highlighter-rouge">isCLIAgent</code>, viewer and editor counts, channels, and the risk fields.</p>

<p>In a flow, apply-to-each over <code class="language-plaintext highlighter-rouge">agents</code> and check <code class="language-plaintext highlighter-rouge">truncated</code> before you treat the result as the whole estate. <code class="language-plaintext highlighter-rouge">riskSignals</code> and <code class="language-plaintext highlighter-rouge">channels</code> are string arrays — <code class="language-plaintext highlighter-rouge">join(item()?['riskSignals'], ', ')</code> flattens them for a table or an email body.</p>

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

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which of my agents run on the GitHub Copilot harness?
List every agent where isCLIAgent is true, with its environment and owner
Show me GitHub Copilot harness agents shared with the entire tenant
Are there any agents where the harness came back as unknown?
Inventory every Copilot Studio agent in my tenant
Show me agents with a Medium or High risk level and explain why
Which agents are missing a Microsoft Entra Agent ID?
List the agents in my default environment and who can edit them
</code></pre></div></div>

<p>With <a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-generative-actions">generative orchestration</a> enabled, the agent can chain the inventory into <code class="language-plaintext highlighter-rouge">admin_get_settings</code> or <code class="language-plaintext highlighter-rouge">admin_get_security_recommendations</code> to check whether the environments holding the priciest agents are governed at all.</p>

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

<p>No new scope. <code class="language-plaintext highlighter-rouge">resourcequery</code> has no documented permission to grant, and it was verified to work with the delegated scopes already on the app registration plus a Power Platform admin role.</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 this time. No scopes changed.</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/power-platform/admin/programmability-and-extensibility/powerplatform-api-reference">Power Platform API reference</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/programmability-permission-reference">Permissions reference</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/agents-experience/billing-credit-overview">GitHub Copilot harness billing</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/requirements-messages-management#copilot-credits-billing-rates">Standard harness billing rates</a></li>
  <li><a href="https://techcommunity.microsoft.com/blog/copilot-studio-blog/more-powerful-agents-and-workflows-for-autonomous-business-processes-introducing/4542969">Introducing a new harness for Copilot Studio</a></li>
  <li><a href="https://learn.microsoft.com/entra/identity/agent-id/agent-id-overview">Microsoft Entra Agent ID</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/managed-environment-overview">Managed Environments overview</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="Agent Inventory" /><category term="GitHub Copilot Harness" /><category term="Copilot Credits" /><category term="Entra Agent ID" /><category term="Power Automate" /><summary type="html"><![CDATA[Version 1.2 of the Power Platform Admin custom MCP connector adds admin_list_agents. Its isCLIAgent field is the only tenant-wide way to tell Standard harness agents from GitHub Copilot harness agents, which cost 5 to 15 times more per run.]]></summary></entry><entry><title type="html">Copilot Studio agents as code: field notes from the Standard and GitHub Copilot harnesses</title><link href="https://troystaylor.com/copilot%20studio/power%20platform/2026-08-06-copilot-studio-agents-as-code.html" rel="alternate" type="text/html" title="Copilot Studio agents as code: field notes from the Standard and GitHub Copilot harnesses" /><published>2026-08-06T14:00:00+00:00</published><updated>2026-08-06T14:00:00+00:00</updated><id>https://troystaylor.com/copilot%20studio/power%20platform/copilot-studio-agents-as-code</id><content type="html" xml:base="https://troystaylor.com/copilot%20studio/power%20platform/2026-08-06-copilot-studio-agents-as-code.html"><![CDATA[<p>Microsoft <a href="https://techcommunity.microsoft.com/blog/copilot-studio-blog/more-powerful-agents-and-workflows-for-autonomous-business-processes-introducing/4542969">made the GitHub Copilot harness generally available</a> on August 3, after a two-month preview, and gave it its name. Copilot Studio now has three harnesses: Copilot Chat, Standard, and GitHub Copilot.</p>

<p>The <a href="https://learn.microsoft.com/microsoft-copilot-studio/visual-studio-code-extension-overview">Copilot Studio extension for VS Code</a> clones a live agent to disk as YAML, lets you edit it, and pushes the change back. I cloned one agent on the Standard harness and one on the GitHub Copilot harness in a live tenant and edited both to find out what actually round-trips.</p>

<p>Three findings, in order of how much they should change your decisions:</p>

<ol>
  <li><strong>The two harnesses I tested are a price class apart.</strong> Roughly an order of magnitude per interaction. Employee-facing Standard harness agents are included in Microsoft 365 Copilot licensing, and GitHub Copilot harness agents are billed for all work regardless of it, which Microsoft now states outright.</li>
  <li><strong>Microsoft’s extension documentation disagrees with the product in eight places.</strong> Two of them break your files.</li>
  <li><strong>A working setup.</strong> Instructions, a skill, two prompts, and a script that reports what your agent actually contains.</li>
</ol>

<h2 id="what-projects-to-disk">What projects to disk</h2>

<p>The round trip is confirmed. Edit a projected file, apply, and the change reaches the live agent, including entirely new nodes with hand-written ids. Verified on the Standard harness.</p>

<p>What you get to edit differs by harness:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Standard harness</th>
      <th>GitHub Copilot harness</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Authoring cost</td>
      <td>Free until publish</td>
      <td>Metered from first build action</td>
    </tr>
    <tr>
      <td>Topics as code</td>
      <td>Yes</td>
      <td>No topics at all</td>
    </tr>
    <tr>
      <td>Behaviors as code</td>
      <td>n/a</td>
      <td>Files project; apply <strong>untested</strong></td>
    </tr>
    <tr>
      <td><strong>Instructions as code</strong></td>
      <td><strong>No</strong></td>
      <td><strong>Yes</strong></td>
    </tr>
  </tbody>
</table>

<p>A rule-and-topic agent can be built and iterated as code for nothing until publish. A reasoning agent projects more, since its instructions and behaviors both land on disk, but every test run is billed. Testing is the inner loop of agent development, not a final step.</p>

<h2 id="the-harnesses-are-a-price-class-apart">The harnesses are a price class apart</h2>

<p>At $0.01 per Copilot Credit:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Credits</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Standard, classic answer</td>
      <td>1</td>
      <td>$0.01</td>
    </tr>
    <tr>
      <td>Standard, generative answer</td>
      <td>2</td>
      <td>$0.02</td>
    </tr>
    <tr>
      <td>Standard, agent action</td>
      <td>5</td>
      <td>$0.05</td>
    </tr>
    <tr>
      <td>Standard, tenant graph grounding</td>
      <td>10</td>
      <td>$0.10</td>
    </tr>
    <tr>
      <td>Standard, multi-step run (4 agent actions)</td>
      <td>20</td>
      <td>$0.20</td>
    </tr>
    <tr>
      <td>GitHub Copilot, light task</td>
      <td>100–300</td>
      <td>$1.00–$3.00</td>
    </tr>
    <tr>
      <td>GitHub Copilot, medium task</td>
      <td>300–500</td>
      <td>$3.00–$5.00</td>
    </tr>
    <tr>
      <td>GitHub Copilot, heavy task</td>
      <td>&gt;500</td>
      <td>&gt;$5.00</td>
    </tr>
  </tbody>
</table>

<p>A light GitHub Copilot task costs 5 to 15 times a complete Standard harness multi-step run.</p>

<p>Those tiers are runtime figures, and development is additional. Microsoft’s <a href="https://learn.microsoft.com/microsoft-copilot-studio/agents-experience/billing-credit-overview#how-copilot-credits-are-measured">“How Copilot credits are measured”</a> section frames consumption entirely around “how often customers interact.” It contains no development term, no worked example, and no guidance on expected iteration counts. The same page states elsewhere that building, previewing, testing, and generating evaluations all consume credits, but never quantifies them, and never confirms whether a test run bills as a full task. The GA announcement repeats the point without numbers, noting that natural language authoring, evaluations, and testing all fall under usage-based billing on this harness. Budget development separately as iterations × tier cost, and measure your own iteration count, because none is published. The tiers themselves appear on that page only as an image.</p>

<p>Employee-facing Standard harness usage is free for Microsoft 365 Copilot licensed users. Every billable feature in the <a href="https://learn.microsoft.com/microsoft-copilot-studio/requirements-messages-management#copilot-credits-billing-rates">Standard harness rate table</a> carries a “no charge” inclusion when the agent runs under the authenticated licensed user’s identity. Microsoft’s own worked example bills 100 of 150 users for exactly this reason. The GA announcement confirms the inclusion continues for the Standard and Copilot Chat harnesses, and says agents on the GitHub Copilot harness “use usage-based billing for all work, regardless of Microsoft 365 Copilot licensing.”</p>

<p>For an internal agent at 100,000 interactions a year:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Annual</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Standard, M365 Copilot licensed employees</td>
      <td><strong>$0</strong></td>
    </tr>
    <tr>
      <td>Standard, unlicensed, ~20 credits per run</td>
      <td>~$20,000</td>
    </tr>
    <tr>
      <td>GitHub Copilot harness, medium tier</td>
      <td>~$400,000</td>
    </tr>
  </tbody>
</table>

<p>Read the caveats on that inclusion before you plan around it. It’s business-to-employee only, subject to unstated fair-usage limits Microsoft can revise, excludes Computer-Using Agents, and for agent flows applies only to the “When an agent calls the flow” trigger. Reasoning models double-bill, feature rate plus premium AI tools at 10 credits per 1K tokens. Enforcement at 125% of prepaid capacity disables agents rather than throttling them.</p>

<h2 id="choosing">Choosing</h2>

<pre><code class="language-mermaid">flowchart TD
    A[New agent] --&gt; B{Complex&lt;br/&gt;reasoning?}
    B --&gt;|Yes| C[GitHub Copilot harness&lt;br/&gt;Metered from first build action]
    B --&gt;|No| D[Standard harness&lt;br/&gt;Free to iterate until publish]
    D --&gt; E{M365 Copilot&lt;br/&gt;licensed?}
    E --&gt;|Yes| F["$0 runtime"]
    E --&gt;|No| G[Pay per credit]
</code></pre>

<p>Pick the GitHub Copilot harness when you genuinely need goal decomposition, native Word, Excel, or PDF generation, memory, or recovery from failed steps. Nothing else does those.</p>

<p>Pick the Standard harness for threshold lookups, approval routing, and structured Q&amp;A, which is most of what agents get asked to do. Deterministic logic in a <code class="language-plaintext highlighter-rouge">ConditionGroup</code> can’t hallucinate a policy number, costs a fraction to run, iterates free, and is reviewable in Git.</p>

<p>The Copilot Chat harness is the third option, aimed at customizing Microsoft 365 Copilot Chat rather than building a standalone agent. It bills on the same fixed rate card as the Standard harness.</p>

<p>The harness question is really how much of this agent has to sit on the expensive one.</p>

<p>What you can’t currently do is edit a Standard harness agent’s generative instructions as code. Push logic into topics instead.</p>

<h2 id="telling-the-harnesses-apart">Telling the harnesses apart</h2>

<p>I tested the Standard and GitHub Copilot harnesses. Copilot Chat, the third, I didn’t clone.</p>

<p>Microsoft’s extension documentation is banner-marked <strong>Standard harness</strong> throughout, which left open whether GitHub Copilot harness agents could be cloned at all. They can, and they project more.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Standard harness</th>
      <th>GitHub Copilot harness</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AuthoringShape</code></td>
      <td><code class="language-plaintext highlighter-rouge">1</code></td>
      <td><code class="language-plaintext highlighter-rouge">2</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">template</code></td>
      <td><code class="language-plaintext highlighter-rouge">default-2.1.0</code></td>
      <td><code class="language-plaintext highlighter-rouge">cliagent-1.0.0</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">recognizer.kind</code></td>
      <td><code class="language-plaintext highlighter-rouge">GenerativeAIRecognizer</code></td>
      <td><code class="language-plaintext highlighter-rouge">CLICopilotRecognizer</code></td>
    </tr>
    <tr>
      <td>Editable files</td>
      <td><code class="language-plaintext highlighter-rouge">topics/*.mcs.yml</code></td>
      <td><code class="language-plaintext highlighter-rouge">behaviors/*.mcs.yml</code></td>
    </tr>
    <tr>
      <td>Instructions</td>
      <td>git-ignored <code class="language-plaintext highlighter-rouge">botdefinition.json</code></td>
      <td>tracked <code class="language-plaintext highlighter-rouge">settings.mcs.yml</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">botdefinition.json</code></td>
      <td>~750 KB</td>
      <td>~23 KB</td>
    </tr>
    <tr>
      <td>Publisher observed</td>
      <td>Copilot Studio (<code class="language-plaintext highlighter-rouge">cra4a_</code>)</td>
      <td>org default (<code class="language-plaintext highlighter-rouge">Default_</code>)</td>
    </tr>
  </tbody>
</table>

<p>A script in the pack reads <code class="language-plaintext highlighter-rouge">.mcs/conn.json</code> and <code class="language-plaintext highlighter-rouge">settings.mcs.yml</code> and reports which harness you have, what projected, and the node kinds valid for that specific agent:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">github</span><span class="n">/skills/copilot-studio-agent/scripts/Get-AgentSchema.ps1</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">agent</span><span class="w"> </span><span class="nx">folder</span><span class="err">&gt;</span><span class="w">
</span></code></pre></div></div>

<p>The node kinds matter most. It extracts them from the definition rather than from a list someone wrote down:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$component</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$def</span><span class="o">.</span><span class="nf">components</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$field</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="s1">'dialog'</span><span class="p">,</span><span class="w"> </span><span class="s1">'metadata'</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$payload</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$component</span><span class="p">[</span><span class="nv">$field</span><span class="p">]</span><span class="w">
        </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$payload</span><span class="w"> </span><span class="o">-isnot</span><span class="w"> </span><span class="p">[</span><span class="n">string</span><span class="p">])</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">continue</span><span class="w"> </span><span class="p">}</span><span class="w">
        </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$match</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="p">[</span><span class="n">regex</span><span class="p">]::</span><span class="n">Matches</span><span class="p">(</span><span class="nv">$payload</span><span class="p">,</span><span class="w"> </span><span class="s1">'(?m)^\s*(?:-\s*)?kind:\s*([A-Za-z0-9_.]+)'</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="p">[</span><span class="n">void</span><span class="p">]</span><span class="nv">$nodeKinds</span><span class="o">.</span><span class="nf">Add</span><span class="p">(</span><span class="nv">$match</span><span class="o">.</span><span class="n">Groups</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">Value</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>One detail worth copying if you write your own tooling: <code class="language-plaintext highlighter-rouge">botdefinition.json</code> contains keys that differ only by case, <code class="language-plaintext highlighter-rouge">id</code> and <code class="language-plaintext highlighter-rouge">Id</code>. <code class="language-plaintext highlighter-rouge">ConvertFrom-Json</code> throws on that unless you pass <code class="language-plaintext highlighter-rouge">-AsHashtable</code>.</p>

<h2 id="corrections">Corrections</h2>

<p>Where the product disagrees with Microsoft Learn.</p>

<table>
  <thead>
    <tr>
      <th>Docs say</th>
      <th>Product does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">connectioreferences.mcs.yml</code></td>
      <td><code class="language-plaintext highlighter-rouge">connectionreferences.mcs.yml</code></td>
    </tr>
    <tr>
      <td>Name files <code class="language-plaintext highlighter-rouge">.topic.yaml</code> / <code class="language-plaintext highlighter-rouge">.tool.yaml</code></td>
      <td>Clone emits <code class="language-plaintext highlighter-rouge">.mcs.yaml</code>; renaming breaks component mapping</td>
    </tr>
    <tr>
      <td>Topic examples with no <code class="language-plaintext highlighter-rouge">mcs.metadata</code></td>
      <td>Required on every component file</td>
    </tr>
    <tr>
      <td>Five node kinds</td>
      <td>29 in one ordinary agent</td>
    </tr>
    <tr>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">modelDescription</code> is the discovery surface, undocumented</td>
    </tr>
    <tr>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">OnRecognizedIntent</code>, <code class="language-plaintext highlighter-rouge">intent: {}</code>, <code class="language-plaintext highlighter-rouge">inputType</code> / <code class="language-plaintext highlighter-rouge">outputType</code>, undocumented</td>
    </tr>
    <tr>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">behaviors/</code> and <code class="language-plaintext highlighter-rouge">agent.sync.yaml</code>, undocumented</td>
    </tr>
    <tr>
      <td>One folder layout</td>
      <td>Two, keyed on <code class="language-plaintext highlighter-rouge">AuthoringShape</code></td>
    </tr>
  </tbody>
</table>

<p>The first two are the ones that cost you time. The <a href="https://learn.microsoft.com/microsoft-copilot-studio/visual-studio-code-extension-edit-agent-components#agent-file-structure">file structure diagram</a> is missing the <code class="language-plaintext highlighter-rouge">n</code> in <code class="language-plaintext highlighter-rouge">connection</code>, so searching for that filename finds nothing. And the naming conventions in the same article’s best practices section tell you to use <code class="language-plaintext highlighter-rouge">.topic.yaml</code> and <code class="language-plaintext highlighter-rouge">.tool.yaml</code> suffixes, but the extension maps files to remote components by path and name. Rename a cloned file to match the guidance and the mapping breaks.</p>

<p>Three behaviors nobody documents:</p>

<ul>
  <li><strong>Projection emits custom components only.</strong> An agent of nothing but system topics produces no <code class="language-plaintext highlighter-rouge">topics/</code> folder. That’s correct, not a failed clone.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">.mcs/.gitignore</code> contains <code class="language-plaintext highlighter-rouge">*</code>.</strong> The definition is deliberately excluded from source control, so whatever fails to project isn’t versioned at all.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">intent: {}</code> plus <code class="language-plaintext highlighter-rouge">modelDescription</code></strong> gives behavior-style generative selection on the Standard harness.</li>
</ul>

<h2 id="whats-in-the-pack">What’s in the pack</h2>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Kind</th>
      <th>Loads</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.github/instructions/copilot-studio-agent.instructions.md</code></td>
      <td>Instructions</td>
      <td>Automatically, for <code class="language-plaintext highlighter-rouge">*.mcs.yaml</code> and <code class="language-plaintext highlighter-rouge">*.mcs.yml</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.github/skills/copilot-studio-agent/SKILL.md</code></td>
      <td>Skill</td>
      <td>On demand, when you’re authoring</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.github/skills/copilot-studio-agent/scripts/Get-AgentSchema.ps1</code></td>
      <td>Script</td>
      <td>When the skill or a prompt calls it</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.github/prompts/cs-component.prompt.md</code></td>
      <td>Prompt</td>
      <td>When you run <code class="language-plaintext highlighter-rouge">/cs-component</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.github/prompts/cs-sync.prompt.md</code></td>
      <td>Prompt</td>
      <td>When you run <code class="language-plaintext highlighter-rouge">/cs-sync</code></td>
    </tr>
  </tbody>
</table>

<p>The split is deliberate. The instructions file is short and always-on for agent definition files, carrying the handful of rules that prevent a failed apply:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">applyTo</span><span class="pi">:</span> <span class="s2">"</span><span class="s">**/*.mcs.yaml,**/*.mcs.yml"</span>
<span class="na">description</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Hard</span><span class="nv"> </span><span class="s">rules</span><span class="nv"> </span><span class="s">for</span><span class="nv"> </span><span class="s">editing</span><span class="nv"> </span><span class="s">cloned</span><span class="nv"> </span><span class="s">Microsoft</span><span class="nv"> </span><span class="s">Copilot</span><span class="nv"> </span><span class="s">Studio</span><span class="nv"> </span><span class="s">agent</span><span class="nv"> </span><span class="s">definition</span><span class="nv"> </span><span class="s">files</span><span class="nv"> </span><span class="s">(.mcs.yaml</span><span class="nv"> </span><span class="s">/</span><span class="nv"> </span><span class="s">.mcs.yml)</span><span class="nv"> </span><span class="s">managed</span><span class="nv"> </span><span class="s">by</span><span class="nv"> </span><span class="s">the</span><span class="nv"> </span><span class="s">Copilot</span><span class="nv"> </span><span class="s">Studio</span><span class="nv"> </span><span class="s">VS</span><span class="nv"> </span><span class="s">Code</span><span class="nv"> </span><span class="s">extension."</span>
<span class="nn">---</span>
</code></pre></div></div>

<p>The skill carries the full schema and folder layout and only enters context when you actually need it. Under usage-based billing, a fat always-on instruction file is a charge you pay on every turn of every session.</p>

<p>Two rules in the instructions file earn their place:</p>

<ul>
  <li><strong>Never invent a <code class="language-plaintext highlighter-rouge">kind:</code> value.</strong> A guessed kind fails on apply and costs a round trip. If it isn’t in the verified vocabulary, confirm it with <code class="language-plaintext highlighter-rouge">Ctrl+Space</code> or the Problems pane first.</li>
  <li><strong>Never mix harness primitives.</strong> <code class="language-plaintext highlighter-rouge">InlineAgentSkill</code> belongs to the GitHub Copilot harness and <code class="language-plaintext highlighter-rouge">AdaptiveDialog</code> to the Standard harness. Write the wrong one and nothing invokes it.</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">/cs-component</code> prompt opens by running the schema script, so the harness is settled before anything gets written, and it holds you to the description before the body. The description is the only thing the orchestrator reads when deciding whether to invoke a component. Write it as the when, not the what. “Use when the user asks whether a purchase requires competitive bidding” fires. “Bidding logic” doesn’t.</p>

<p><code class="language-plaintext highlighter-rouge">/cs-sync</code> walks the apply ritual in order: preview remote changes, commit local work before retrieving, resolve conflicts explicitly, check the Problems pane, summarize, then apply. The order isn’t cosmetic. Apply is blocked while remote changes are outstanding, and <code class="language-plaintext highlighter-rouge">Get Changes</code> overwrites uncommitted local work.</p>

<p>It also ends by reminding you that apply is not publish. <code class="language-plaintext highlighter-rouge">Copilot Studio: Apply Changes</code> updates the agent in the environment so you can test it in the test pane. End users see nothing until a separate publish.</p>

<h2 id="using-it">Using it</h2>

<p>Copy <code class="language-plaintext highlighter-rouge">.github/</code> into the workspace where you cloned your agent, or clone the agent into the folder that already has it. Then:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Ctrl+Shift+P → Copilot Studio: Clone Agent
</code></pre></div></div>

<p>Once files are on disk, the instructions apply themselves whenever you open a <code class="language-plaintext highlighter-rouge">.mcs.yaml</code>. Ask for the skill by name, or start editing a topic and it gets pulled in.</p>

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

<p>Verified August 2026 against <code class="language-plaintext highlighter-rouge">CopilotStudioSolutionVersion 2026.6.3.20581040</code>, using two agents in a single Dataverse environment, one on the Standard harness and one on the GitHub Copilot harness. The Copilot Chat harness is untested here.</p>

<p>The GitHub Copilot harness reached general availability on August 3, 2026, so anything written about it before that date describes the preview. <code class="language-plaintext highlighter-rouge">cliagent-1.0.0</code> is a 1.0 template and <code class="language-plaintext highlighter-rouge">behaviors/</code> is undocumented, so expect both to move. The node vocabulary is per-agent, so run the script against your own rather than trusting the count above.</p>

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

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Agents%20as%20Code">Copilot Studio agents as code pack</a></li>
  <li><a href="https://techcommunity.microsoft.com/blog/copilot-studio-blog/more-powerful-agents-and-workflows-for-autonomous-business-processes-introducing/4542969">Introducing a new harness for Copilot Studio</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/visual-studio-code-extension-overview">Copilot Studio extension for VS Code</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/visual-studio-code-extension-edit-agent-components">Edit agent components</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/requirements-messages-management#copilot-credits-billing-rates">Standard harness billing rates</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/agents-experience/billing-credit-overview">GitHub Copilot harness billing</a></li>
  <li><a href="https://microsoft.github.io/copilot-studio-estimator/">Copilot Studio agent usage estimator</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Copilot Studio" /><category term="Power Platform" /><category term="Copilot Studio" /><category term="Agents as Code" /><category term="VS Code" /><category term="Copilot Credits" /><category term="GitHub Copilot Harness" /><category term="Standard Harness" /><category term="YAML" /><summary type="html"><![CDATA[Cloning one Copilot Studio agent on the Standard harness and one on the newly GA GitHub Copilot harness: what projects to disk, why the two are a price class apart, eight corrections to Microsoft's documentation, and a VS Code customization pack that keeps applies from failing.]]></summary></entry><entry><title type="html">Power Platform Admin connector adds tenant pool control and Power Automate actions</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-05-power-platform-admin-tenant-pool-draw.html" rel="alternate" type="text/html" title="Power Platform Admin connector adds tenant pool control and Power Automate actions" /><published>2026-08-05T14:00:00+00:00</published><updated>2026-08-05T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/power-platform-admin-tenant-pool-draw</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-05-power-platform-admin-tenant-pool-draw.html"><![CDATA[<p><strong>Draw from Tenant Pool</strong> decides whether an environment spends Copilot Credits from your tenant capacity pool. That pool is enforced monthly, unused credits don’t roll over, and the <a href="https://cdn-dynmedia-1.microsoft.com/is/content/microsoftcorp/microsoft/bade/documents/products-and-services/en-us/ai/Microsoft-Copilot-Studio-Licensing-Guide-August-2026.pdf">August 2026 licensing guide</a> is blunt about the consequence of going over: technical enforcement, up to service denial. One environment overspending is a tenant-wide problem.</p>

<p>The Power Platform admin center exposes the setting one environment at a time. If you run 40 environments and one agent in a sandbox is draining shared capacity, turning it off everywhere but production means 40 trips through the UI.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Platform%20Admin">Power Platform Admin v1.1</a> adds two tools for that setting. The same release makes the connector dual-mode, so Power Automate gets typed actions next to the MCP endpoint that Copilot Studio uses.</p>

<p>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. This one covers what changed.</p>

<h2 id="whats-new-in-11">What’s new in 1.1</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>v1.0</th>
      <th>v1.1</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MCP tools</td>
      <td>12</td>
      <td>14</td>
    </tr>
    <tr>
      <td>Power Automate actions</td>
      <td>None</td>
      <td>6 typed operations</td>
    </tr>
    <tr>
      <td>Environment pickers</td>
      <td>None</td>
      <td>Dynamic dropdown on every action</td>
    </tr>
    <tr>
      <td>APIs called</td>
      <td><code class="language-plaintext highlighter-rouge">api.powerplatform.com</code></td>
      <td>Adds the tenant-routed licensing host</td>
    </tr>
  </tbody>
</table>

<h2 id="the-two-new-tools">The two new tools</h2>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_get_tenant_pool_draw</code></td>
      <td>Check whether an environment draws Copilot Credit capacity from the tenant pool</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_set_tenant_pool_draw</code></td>
      <td>Turn that draw on or off</td>
    </tr>
  </tbody>
</table>

<p>Both act on two entitlements, <code class="language-plaintext highlighter-rouge">MCSMessages</code> and <code class="language-plaintext highlighter-rouge">MCSSessions</code>, because the single toggle in the admin center covers both:</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="kt">string</span><span class="p">[]</span> <span class="n">TENANT_POOL_ENTITLEMENTS</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>Those IDs are older than the billing vocabulary. Microsoft renamed Copilot Studio messages to Copilot Credits in September 2025, and the current licensing guide has no message or session meter left in it—one credit pack is 25,000 Copilot Credits per month, and pay-as-you-go bills at $0.01 per credit. The entitlement IDs on the API never followed the rename, so the connector uses the names the service still answers to.</p>

<h2 id="this-route-is-unsupported">This route is unsupported</h2>

<p><code class="language-plaintext highlighter-rouge">licensing/allocations</code> isn’t in the <a href="https://learn.microsoft.com/rest/api/power-platform/">published Power Platform REST reference</a>. The documented licensing surface is <a href="https://learn.microsoft.com/rest/api/power-platform/licensing/currency-allocation">Currency Allocation</a>, which shares the namespace but not this route. The endpoint can change or stop working without notice.</p>

<p>Treat these two tools as a way to fix a org-wide capacity problem in one conversation, not as a permanent integration. Read before you write, and check the result in the admin center the first few times.</p>

<h2 id="licensing-answers-on-a-different-host">Licensing answers on a different host</h2>

<p>Environments, settings, governance, connectors, and apps all answer at <code class="language-plaintext highlighter-rouge">api.powerplatform.com</code>. Licensing allocations don’t. That route is tenant-routed, and the host is built from the tenant GUID: lowercased, dashes stripped, split after the first 30 hex characters.</p>

<pre><code class="language-mermaid">flowchart TD
    A[Copilot Studio agent&lt;br/&gt;or Power Automate flow] --&gt; B[script.csx]
    B --&gt; C[api.powerplatform.com&lt;br/&gt;environments, settings, governance, inventory]
    B --&gt; D["first30hex.last2hex.tenant.api.powerplatform.com&lt;br/&gt;licensing allocations"]
</code></pre>

<p>Send a licensing call to the wrong host shape and you get <code class="language-plaintext highlighter-rouge">404 RouteNotFound</code>.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="kt">string</span> <span class="nf">ResolveTenantHost</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">dashless</span> <span class="p">=</span> <span class="nf">ResolveTenantId</span><span class="p">().</span><span class="nf">Replace</span><span class="p">(</span><span class="s">"-"</span><span class="p">,</span> <span class="s">""</span><span class="p">).</span><span class="nf">ToLowerInvariant</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">dashless</span><span class="p">.</span><span class="n">Length</span> <span class="p">!=</span> <span class="m">32</span><span class="p">)</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span>
            <span class="s">"Tenant id is not 32 hex characters; cannot build the licensing host."</span><span class="p">);</span>

    <span class="k">return</span> <span class="s">$"</span><span class="p">{</span><span class="n">dashless</span><span class="p">.</span><span class="nf">Substring</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">30</span><span class="p">)}</span><span class="s">.</span><span class="p">{</span><span class="n">dashless</span><span class="p">.</span><span class="nf">Substring</span><span class="p">(</span><span class="m">30</span><span class="p">)}{</span><span class="n">TENANT_HOST_SUFFIX</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The tenant ID comes from the token, not from a connection parameter. <code class="language-plaintext highlighter-rouge">IScriptContext</code> has no way to read connection parameters, so the script decodes the <code class="language-plaintext highlighter-rouge">tid</code> claim out of the bearer token it already receives:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">segments</span> <span class="p">=</span> <span class="n">auth</span><span class="p">.</span><span class="n">Parameter</span><span class="p">.</span><span class="nf">Split</span><span class="p">(</span><span class="sc">'.'</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">payload</span> <span class="p">=</span> <span class="n">segments</span><span class="p">[</span><span class="m">1</span><span class="p">].</span><span class="nf">Replace</span><span class="p">(</span><span class="sc">'-'</span><span class="p">,</span> <span class="sc">'+'</span><span class="p">).</span><span class="nf">Replace</span><span class="p">(</span><span class="sc">'_'</span><span class="p">,</span> <span class="sc">'/'</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">payload</span><span class="p">.</span><span class="n">Length</span> <span class="p">%</span> <span class="m">4</span> <span class="p">==</span> <span class="m">2</span><span class="p">)</span> <span class="n">payload</span> <span class="p">+=</span> <span class="s">"=="</span><span class="p">;</span>
<span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">payload</span><span class="p">.</span><span class="n">Length</span> <span class="p">%</span> <span class="m">4</span> <span class="p">==</span> <span class="m">3</span><span class="p">)</span> <span class="n">payload</span> <span class="p">+=</span> <span class="s">"="</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">claims</span> <span class="p">=</span> <span class="n">JObject</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span>
    <span class="n">Encoding</span><span class="p">.</span><span class="n">UTF8</span><span class="p">.</span><span class="nf">GetString</span><span class="p">(</span><span class="n">Convert</span><span class="p">.</span><span class="nf">FromBase64String</span><span class="p">(</span><span class="n">payload</span><span class="p">)));</span>
<span class="kt">var</span> <span class="n">tid</span> <span class="p">=</span> <span class="n">claims</span><span class="p">[</span><span class="s">"tid"</span><span class="p">]?.</span><span class="nf">ToString</span><span class="p">();</span>
</code></pre></div></div>

<p>Setup stays the same as v1.0. No extra field on the connection, no tenant ID to paste.</p>

<h2 id="a-missing-document-is-the-answer">A missing document is the answer</h2>

<p>An environment that has never had capacity allocated has no allocation document, and the API returns 404. That 404 isn’t a failure. It’s the platform saying the environment draws from the pool by default.</p>

<p><code class="language-plaintext highlighter-rouge">RouteNotFound</code> also returns 404, so the script reads the error code in the body instead of trusting the status:</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">result</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">NotFound</span> <span class="p">&amp;&amp;</span>
    <span class="n">result</span><span class="p">.</span><span class="n">Body</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">&amp;&amp;</span>
    <span class="n">result</span><span class="p">.</span><span class="n">Body</span><span class="p">.</span><span class="nf">IndexOf</span><span class="p">(</span><span class="s">"AllocationDocumentDoesNotExist"</span><span class="p">,</span>
        <span class="n">StringComparison</span><span class="p">.</span><span class="n">OrdinalIgnoreCase</span><span class="p">)</span> <span class="p">&gt;=</span> <span class="m">0</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="k">null</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A null document flows through to a plain answer for the agent:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>No allocation document exists for this environment,
so it draws from the tenant pool by default.
</code></pre></div></div>

<h2 id="writing-back-without-clobbering">Writing back without clobbering</h2>

<p>The read and the write use different route names for the same data. <code class="language-plaintext highlighter-rouge">GET /licensing/allocationsV2</code> requires a <code class="language-plaintext highlighter-rouge">$filter</code> on the environment and entitlements. <code class="language-plaintext highlighter-rouge">PUT /licensing/allocations</code> takes the whole document.</p>

<p>That PUT is a full upsert with no ETag. A blind write would erase any capacity allocation or enforcement rule someone else configured on the environment, and nothing would warn you. So the set tool reads first and changes only the <code class="language-plaintext highlighter-rouge">TenantPool</code> rule:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">rule</span> <span class="p">=</span> <span class="nf">FindTenantPoolRule</span><span class="p">(</span><span class="n">entitlement</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">rule</span> <span class="p">==</span> <span class="k">null</span><span class="p">)</span>
    <span class="n">rules</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span> <span class="n">JObject</span>
    <span class="p">{</span>
        <span class="p">[</span><span class="s">"ruleType"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"TenantPool"</span><span class="p">,</span>
        <span class="p">[</span><span class="s">"enabled"</span><span class="p">]</span> <span class="p">=</span> <span class="n">enabled</span>
    <span class="p">});</span>
<span class="k">else</span>
    <span class="n">rule</span><span class="p">[</span><span class="s">"enabled"</span><span class="p">]</span> <span class="p">=</span> <span class="n">enabled</span><span class="p">;</span>
</code></pre></div></div>

<p>Enforcement rules also carry <code class="language-plaintext highlighter-rouge">Alert</code>, <code class="language-plaintext highlighter-rouge">PayGo</code>, and <code class="language-plaintext highlighter-rouge">Deny</code> types. Rewriting one entry in the array keeps the rest intact.</p>

<p>Read-modify-write narrows the window, it doesn’t close it. With no ETag there’s no optimistic concurrency, so an admin editing the same environment in the admin center at the same moment can still lose a change.</p>

<h2 id="six-typed-actions-for-power-automate">Six typed actions for Power Automate</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Method</th>
      <th>Path</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Invoke Power Platform Admin MCP</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">/mcp</code></td>
    </tr>
    <tr>
      <td>List Environments</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">/admin/environments</code></td>
    </tr>
    <tr>
      <td>Get Environment</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">/admin/environment</code></td>
    </tr>
    <tr>
      <td>Get Settings</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">/admin/settings</code></td>
    </tr>
    <tr>
      <td>Update Settings</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">/admin/settings/update</code></td>
    </tr>
    <tr>
      <td>Get Draw From Tenant Pool</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">/admin/tenantpool</code></td>
    </tr>
    <tr>
      <td>Set Draw From Tenant Pool</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">/admin/tenantpool/update</code></td>
    </tr>
  </tbody>
</table>

<p>Every <code class="language-plaintext highlighter-rouge">environmentId</code> parameter is a picker, not a GUID field:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"x-ms-dynamic-values"</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">"GetEnvironmentDropdown"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"value-path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"id"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"value-title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"name"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">GetEnvironmentDropdown</code> is marked <code class="language-plaintext highlighter-rouge">x-ms-visibility: internal</code>, so makers see environment names in a list and never see the operation that fills it.</p>

<p>Both surfaces call the same handlers. The typed <code class="language-plaintext highlighter-rouge">GetTenantPoolDraw</code> operation and the <code class="language-plaintext highlighter-rouge">admin_get_tenant_pool_draw</code> MCP tool both land in <code class="language-plaintext highlighter-rouge">HandleGetTenantPoolDraw</code>, so a flow and an agent can’t drift apart.</p>

<h2 id="permissions-to-add">Permissions to add</h2>

<p>Add two delegated permissions to the app registration from v1.0, on <code class="language-plaintext highlighter-rouge">Power Platform API</code> (resource ID <code class="language-plaintext highlighter-rouge">8578e004-a5c6-46e7-913e-12f58912df43</code>):</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Licensing.Allocations.Read</code></li>
  <li><code class="language-plaintext highlighter-rouge">Licensing.Allocations.ReadWrite</code></li>
</ul>

<p>The caller needs <strong>Power Platform Administrator</strong> or <strong>Global Administrator</strong> for the tenant pool tools. These are the roles that gate licensing and capacity in the admin center, and an environment-scoped System Administrator won’t clear them.</p>

<p>Grant admin consent for the new scopes, then delete and recreate the connection so the token carries them.</p>

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

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Does my sandbox environment draw Copilot Studio capacity from the tenant pool?
Stop the sandbox environments from drawing from the Copilot Studio tenant pool
Turn tenant pool draw back on for [environment name]
</code></pre></div></div>

<p>With <a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-generative-actions">generative orchestration</a> enabled, the agent chains <code class="language-plaintext highlighter-rouge">admin_list_environments</code> into <code class="language-plaintext highlighter-rouge">admin_get_tenant_pool_draw</code> to sweep every environment before you change anything.</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 before you run it. 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>

<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/power-platform/admin/programmability-and-extensibility/powerplatform-api-reference">Power Platform API reference</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/programmability-permission-reference">Permissions reference</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/currency-allocation">Currency Allocation, the supported licensing allocation API</a></li>
  <li><a href="https://cdn-dynmedia-1.microsoft.com/is/content/microsoftcorp/microsoft/bade/documents/products-and-services/en-us/ai/Microsoft-Copilot-Studio-Licensing-Guide-August-2026.pdf">Microsoft Copilot Studio Licensing Guide, August 2026</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/faq-billing-licensing">FAQ for Copilot Studio billing and licensing</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="PPAC" /><category term="Power Automate" /><summary type="html"><![CDATA[Version 1.1 of the Power Platform Admin custom MCP connector adds two tools for the Copilot Studio Draw from Tenant Pool setting, plus six typed Power Automate actions with environment dropdowns.]]></summary></entry><entry><title type="html">Graph Mission Control exposes Microsoft Graph through three operations</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-08-04-graph-mission-control-mcp-connector.html" rel="alternate" type="text/html" title="Graph Mission Control exposes Microsoft Graph through three operations" /><published>2026-08-04T14:00:00+00:00</published><updated>2026-08-04T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/graph-mission-control-mcp-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-08-04-graph-mission-control-mcp-connector.html"><![CDATA[<p>Work IQ meters every question your agent asks. API access is independent of Microsoft 365 Copilot licensing and runs on <a href="https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/work-iq/#access-and-pricing">usage-based billing with Copilot Credits</a>, and custom and third-party agents are billed on usage even when the user already holds a Copilot license. For reasoning across everything a person touches, that’s a fair trade.</p>

<p>Plenty of agent work is a plain read, though. What meetings are today, who reports to whom, every unread message from finance, when five people are free. Microsoft Graph answers those deterministically and consumes no credits doing it.</p>

<p>Surface area is what makes that hard. Graph has hundreds of operations, and registering them as typed tools means an agent pays for every schema in its context window before it does any work. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Graph%20Mission%20Control">Graph Mission Control</a> registers three instead, and lets the model discover the rest on demand.</p>

<p>It’s built on the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Power%20Mission%20Control%20Template">Power Mission Control Template</a>, so the protocol and framework details live there. This post covers what’s specific to Graph.</p>

<h2 id="why-three-operations">Why three operations</h2>

<table>
  <thead>
    <tr>
      <th>Approach</th>
      <th><code class="language-plaintext highlighter-rouge">tools/list</code> cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>One typed tool per Graph operation</td>
      <td>~500 tokens × N</td>
    </tr>
    <tr>
      <td>Graph Mission Control</td>
      <td>~1,500 tokens, fixed</td>
    </tr>
  </tbody>
</table>

<p>The planner calls <code class="language-plaintext highlighter-rouge">scan_graph</code> to find what it needs, then <code class="language-plaintext highlighter-rouge">launch_graph</code> to run it. Operation schemas are fetched on demand, never injected up front.</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">scan_graph</code></td>
      <td>Find Graph operations matching an intent. Returns endpoint, method, and parameters.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">launch_graph</code></td>
      <td>Execute one Graph v1.0 call.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sequence_graph</code></td>
      <td>Execute up to 20 calls in a single round trip via Graph <code class="language-plaintext highlighter-rouge">$batch</code>.</td>
    </tr>
  </tbody>
</table>

<h2 id="two-heads-one-index">Two heads, one index</h2>

<p>The repo ships two independent deployments that compile against the same capability index.</p>

<pre><code class="language-mermaid">flowchart TD
    A[graph-capability-index.json&lt;br/&gt;82 operations] --&gt; B[Connector head&lt;br/&gt;script.csx]
    A --&gt; C[Federated head&lt;br/&gt;.NET 10 MCP server]
    B --&gt; D[Copilot Studio&lt;br/&gt;MCP endpoint at /mcp]
    B --&gt; E[Power Automate&lt;br/&gt;ScanGraph, LaunchGraph, SequenceGraph]
    C --&gt; F[Microsoft 365 Copilot&lt;br/&gt;federated connector]
</code></pre>

<p>The connector head is dual-mode. Copilot Studio talks to the MCP endpoint at <code class="language-plaintext highlighter-rouge">/mcp</code> with <a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-generative-actions">generative orchestration</a> enabled, and Power Automate gets the typed <code class="language-plaintext highlighter-rouge">ScanGraph</code>, <code class="language-plaintext highlighter-rouge">LaunchGraph</code>, and <code class="language-plaintext highlighter-rouge">SequenceGraph</code> actions with full schemas and dynamic content. Both route through the same index and the same proxy, so behavior can’t drift between them.</p>

<p>The federated head is a public HTTPS MCP server. Federated connectors require one, and a Power Platform custom connector isn’t a public endpoint, so that surface needs its own deployment.</p>

<p>Deploy either head without the other.</p>

<h2 id="the-capability-index">The capability index</h2>

<p><code class="language-plaintext highlighter-rouge">graph-capability-index.json</code> is the source of truth: 82 Graph v1.0 operations, 50 read and 32 write.</p>

<table>
  <thead>
    <tr>
      <th>Domain</th>
      <th>Operations</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>mail</td>
      <td>14</td>
    </tr>
    <tr>
      <td>files</td>
      <td>13</td>
    </tr>
    <tr>
      <td>calendar</td>
      <td>12</td>
    </tr>
    <tr>
      <td>teams</td>
      <td>12</td>
    </tr>
    <tr>
      <td>people</td>
      <td>8</td>
    </tr>
    <tr>
      <td>sites</td>
      <td>8</td>
    </tr>
    <tr>
      <td>tasks</td>
      <td>8</td>
    </tr>
    <tr>
      <td>groups</td>
      <td>3</td>
    </tr>
    <tr>
      <td>insights</td>
      <td>3</td>
    </tr>
    <tr>
      <td>search</td>
      <td>1</td>
    </tr>
  </tbody>
</table>

<p>Each entry carries a <code class="language-plaintext highlighter-rouge">readOnly</code> flag. The connector head ignores it—Copilot Studio and Power Automate get the full surface—but the federated head depends on it.</p>

<p>That flag can’t be derived from the HTTP verb. Three operations are <code class="language-plaintext highlighter-rouge">POST</code> and semantically reads:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /me/findMeetingTimes
POST /me/calendar/getSchedule
POST /search/query
</code></pre></div></div>

<p>A verb-based filter would drop <code class="language-plaintext highlighter-rouge">/search/query</code>, which is the highest-value read in the whole index.</p>

<h2 id="batching">Batching</h2>

<p><code class="language-plaintext highlighter-rouge">sequence_graph</code> maps onto the native Graph <code class="language-plaintext highlighter-rouge">$batch</code> endpoint rather than issuing calls one at a time, so twenty operations cost one round trip. Two constraints follow from Graph’s implementation:</p>

<ul>
  <li><strong>20 requests maximum.</strong> <code class="language-plaintext highlighter-rouge">MaxBatchSize</code> matches it.</li>
  <li><strong>Sub-request URLs must be version-relative</strong>—<code class="language-plaintext highlighter-rouge">/me/messages</code>, not <code class="language-plaintext highlighter-rouge">/v1.0/me/messages</code>. This is why <code class="language-plaintext highlighter-rouge">DefaultApiVersion</code> is left unset and the version lives in <code class="language-plaintext highlighter-rouge">BaseApiUrl</code> only. Setting both would prefix every sub-request URL and Graph would reject the batch.</li>
</ul>

<p>A partial failure isn’t a failure. Individual requests can fail while the sequence returns 200, with per-request <code class="language-plaintext highlighter-rouge">status</code> and <code class="language-plaintext highlighter-rouge">success</code>.</p>

<h2 id="authentication-on-the-connector-head">Authentication on the connector head</h2>

<p>Entra ID with on-behalf-of, so calls run as the signed-in user and Graph enforces that user’s own permissions. Graph publishes its own app registration and scopes, so you only need one app registration—the connector app.</p>

<p>Add the delegated Graph permissions your scenarios need and grant admin consent. Start narrow: <code class="language-plaintext highlighter-rouge">User.Read</code>, <code class="language-plaintext highlighter-rouge">Mail.Read</code>, and <code class="language-plaintext highlighter-rouge">Calendars.Read</code> cover most read scenarios. The <code class="language-plaintext highlighter-rouge">aad</code> identity provider hardcodes <code class="language-plaintext highlighter-rouge">scope=openid</code> in the on-behalf-of exchange and has no scopes parameter, so pre-consented delegated permissions are what actually govern access.</p>

<p>Two setup steps trip people up.</p>

<p><strong>The per-call sign-in prompt.</strong> On the same app registration, go to <strong>Expose an API</strong>, add a scope named <code class="language-plaintext highlighter-rouge">access_as_user</code>, then add <code class="language-plaintext highlighter-rouge">fe053c5f-3692-4f14-aef2-ee34fc081cae</code> under <strong>Authorized client applications</strong>. That’s the Azure API Connections service principal. Without it, users are prompted to sign in on every call.</p>

<p><strong>The redirect URI is a second pass.</strong> <code class="language-plaintext highlighter-rouge">apiProperties.json</code> sets <code class="language-plaintext highlighter-rouge">redirectMode: GlobalPerConnector</code>, which mints a URI carrying the connector’s own name and a hash of its id:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://global.consent.azure-apim.net/redirect/new-5fgraph-20mission-20control-5f&lt;hash&gt;
</code></pre></div></div>

<p>You can’t know it until the connector exists. Deploy first, read the exact value off the connector’s <strong>Security</strong> tab, then add it to the app registration as a <strong>Web</strong> redirect URI. Until you do, every connection attempt fails with <code class="language-plaintext highlighter-rouge">AADSTS50011</code>. The generic <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code> doesn’t satisfy it, so keep both.</p>

<p>When a call fails, the two statuses mean different things:</p>

<table>
  <thead>
    <tr>
      <th>Status</th>
      <th>Meaning</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>401</td>
      <td>Graph rejected the token—expired, or wrong audience</td>
      <td>Reconnect. If it persists, check <code class="language-plaintext highlighter-rouge">resourceUri</code> is <code class="language-plaintext highlighter-rouge">https://graph.microsoft.com</code>.</td>
    </tr>
    <tr>
      <td>403</td>
      <td>Token is valid, a delegated permission is missing</td>
      <td>Graph names the permission in the error. Add it and grant admin consent.</td>
    </tr>
  </tbody>
</table>

<p>Graph’s own error code and message pass through, so a 403 tells you which permission to add.</p>

<h2 id="deploy-the-connector-head">Deploy the connector head</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="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="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.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></code></pre></div></div>

<p>Pass <code class="language-plaintext highlighter-rouge">--environment</code> explicitly even though <code class="language-plaintext highlighter-rouge">pac auth create</code> already named one. Relying on the auth profile alone fails with an opaque <code class="language-plaintext highlighter-rouge">An unexpected error occurred</code>.</p>

<p>Then configure OAuth on the connector’s <strong>Security</strong> tab: identity provider Azure Active Directory, plus the client ID and secret from your app registration.</p>

<p>Earlier guidance said to omit <code class="language-plaintext highlighter-rouge">apiProperties.json</code> because PAC CLI 2.8.1 failed whenever OAuth <code class="language-plaintext highlighter-rouge">connectionParameters</code> were present. That’s fixed as of 2.9.3, so all three files go in one command and the two-step workaround is gone.</p>

<p><code class="language-plaintext highlighter-rouge">script.csx</code> is compiled by the platform at deploy time, not locally, so a real <code class="language-plaintext highlighter-rouge">pac connector create</code> is the only validation.</p>

<h2 id="the-federated-head">The federated head</h2>

<p><code class="language-plaintext highlighter-rouge">Graph Mission Control MCP/</code> surfaces the same operations inside Microsoft 365 Copilot as a federated connector. It ships two tools, not three, because launch can write:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">search_work</code></td>
      <td>Search across mail, files, events, Teams messages, people, and sites</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fetch_work</code></td>
      <td>Read one Graph resource by path</td>
    </tr>
  </tbody>
</table>

<h3 id="read-only-by-construction">Read-only by construction</h3>

<p>Federated connectors are read-only by contract, and each tool must carry the <code class="language-plaintext highlighter-rouge">readOnlyHint</code> annotation. That annotation is checked at registration time and never enforced at runtime, so the server enforces it itself in two independent layers:</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Guarantee</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fetch_work</code> only ever issues <code class="language-plaintext highlighter-rouge">GET</code></td>
      <td>No request can mutate anything</td>
    </tr>
    <tr>
      <td>Path must match a <code class="language-plaintext highlighter-rouge">readOnly: true</code> index entry</td>
      <td>Reads stay inside the approved surface</td>
    </tr>
  </tbody>
</table>

<p>Both are necessary. <code class="language-plaintext highlighter-rouge">/me/messages</code> and <code class="language-plaintext highlighter-rouge">/chats/{chatId}/messages</code> each have a read and a write at the same path, distinguished only by method—the path guard allows them and the GET-only rule blocks the write. Meanwhile GET alone would happily read <code class="language-plaintext highlighter-rouge">/servicePrincipals</code> and <code class="language-plaintext highlighter-rouge">/auditLogs/signIns</code>, which the path guard rejects.</p>

<p><code class="language-plaintext highlighter-rouge">ToolRegistry.Add</code> throws if a tool declares <code class="language-plaintext highlighter-rouge">readOnlyHint: false</code>, so a violation fails startup rather than a request.</p>

<h3 id="search-splits-by-entity-type-group">Search splits by entity-type group</h3>

<p>Graph rejects most cross-type searches with <code class="language-plaintext highlighter-rouge">Invalid entity type combination</code>. Only certain types may share a single <code class="language-plaintext highlighter-rouge">/search/query</code> request:</p>

<table>
  <thead>
    <tr>
      <th>Group</th>
      <th>Types</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Files</td>
      <td><code class="language-plaintext highlighter-rouge">drive</code>, <code class="language-plaintext highlighter-rouge">driveItem</code>, <code class="language-plaintext highlighter-rouge">list</code>, <code class="language-plaintext highlighter-rouge">listItem</code>, <code class="language-plaintext highlighter-rouge">site</code>, <code class="language-plaintext highlighter-rouge">externalItem</code></td>
    </tr>
    <tr>
      <td>Mail</td>
      <td><code class="language-plaintext highlighter-rouge">message</code></td>
    </tr>
    <tr>
      <td>Calendar</td>
      <td><code class="language-plaintext highlighter-rouge">event</code></td>
    </tr>
    <tr>
      <td>Teams</td>
      <td><code class="language-plaintext highlighter-rouge">chatMessage</code></td>
    </tr>
    <tr>
      <td>People</td>
      <td><code class="language-plaintext highlighter-rouge">person</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">search_work</code> buckets the requested sources by group, issues one search per group, and merges the hits. A failure in one bucket is reported alongside the results that succeeded.</p>

<p>This isn’t theoretical. The tool’s own defaults are mail, files, and events, which is an illegal combination, so every default call failed until the bucketing went in.</p>

<h3 id="discovery-goes-through-the-tools">Discovery goes through the tools</h3>

<p>Federated connectors expose tools only. Microsoft’s documentation describes tool availability exclusively, and MCP resources and prompts never appear for this surface. Copilot Studio does support resources, but the server owner has to configure the resource as the output of a tool, so resources are never independently browsable on any Microsoft surface.</p>

<p>That leaves the tools as the only discovery channel, and fifty readable operations would otherwise be reachable but invisible. So <code class="language-plaintext highlighter-rouge">fetch_work</code> returns the full catalog of readable paths, grouped by domain, whenever it gets a path it doesn’t recognize. A dead end becomes self-correction on the first miss. The catalog runs about 1.3 KB for 50 operations and is produced only on a miss, so it costs nothing in the <code class="language-plaintext highlighter-rouge">tools/list</code> payload a planner loads every session.</p>

<h3 id="what-it-adds-over-copilot-on-its-own">What it adds over Copilot on its own</h3>

<p>These aren’t competitors on the same axis. Microsoft 365 Copilot reasons over a semantic index of your content; this server makes live, deterministic Graph reads. Where they overlap, Copilot on its own is usually better—an index plus a model beats fifty REST endpoints at summarizing, fuzzy intent, and cross-document reasoning. This server returns JSON. It doesn’t reason.</p>

<p>The value sits in the parts that don’t overlap. Roughly 30 of the 50 read-only capabilities have no native equivalent, because they’re configuration, structure, and computation rather than content:</p>

<table>
  <thead>
    <tr>
      <th>Ask</th>
      <th>Capability</th>
      <th>Why the index can’t answer it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Working hours, time zone, auto-reply</td>
      <td><code class="language-plaintext highlighter-rouge">get_mailbox_settings</code></td>
      <td>Mailbox configuration, not content</td>
    </tr>
    <tr>
      <td>When are we both free?</td>
      <td><code class="language-plaintext highlighter-rouge">get_free_busy_schedule</code></td>
      <td>Live availability computation</td>
    </tr>
    <tr>
      <td>Find a slot for these five people</td>
      <td><code class="language-plaintext highlighter-rouge">find_meeting_times</code></td>
      <td>Graph-side scheduling algorithm</td>
    </tr>
    <tr>
      <td>Who reports to whom</td>
      <td><code class="language-plaintext highlighter-rouge">get_manager</code>, <code class="language-plaintext highlighter-rouge">list_direct_reports</code></td>
      <td>Directory graph traversal</td>
    </tr>
    <tr>
      <td>My Planner and To Do tasks</td>
      <td><code class="language-plaintext highlighter-rouge">list_my_planner_tasks</code>, <code class="language-plaintext highlighter-rouge">list_todo_tasks</code></td>
      <td>Separate task stores</td>
    </tr>
    <tr>
      <td>Rows in a SharePoint list</td>
      <td><code class="language-plaintext highlighter-rouge">list_site_lists</code>, <code class="language-plaintext highlighter-rouge">list_list_items</code></td>
      <td>Structured list items, not documents</td>
    </tr>
    <tr>
      <td>Meeting transcript text</td>
      <td><code class="language-plaintext highlighter-rouge">list_online_meeting_transcripts</code>, <code class="language-plaintext highlighter-rouge">get_transcript_content</code></td>
      <td>Explicit transcript fetch</td>
    </tr>
    <tr>
      <td>What’s trending around me</td>
      <td><code class="language-plaintext highlighter-rouge">list_trending_documents</code>, <code class="language-plaintext highlighter-rouge">list_used_documents</code></td>
      <td>Graph insights signals</td>
    </tr>
    <tr>
      <td>Group membership</td>
      <td><code class="language-plaintext highlighter-rouge">list_group_members</code>, <code class="language-plaintext highlighter-rouge">get_group</code></td>
      <td>Directory objects</td>
    </tr>
  </tbody>
</table>

<p>Three structural differences go beyond the capability list:</p>

<ul>
  <li><strong>Freshness.</strong> Reads hit Graph at request time. The semantic index has ingestion lag, so “did the contract arrive yet” is answered correctly here and possibly not there.</li>
  <li><strong>Determinism.</strong> <code class="language-plaintext highlighter-rouge">$filter</code>, <code class="language-plaintext highlighter-rouge">$select</code>, <code class="language-plaintext highlighter-rouge">$orderby</code>, <code class="language-plaintext highlighter-rouge">$top</code>, and <code class="language-plaintext highlighter-rouge">@odata.nextLink</code> paging return complete, exactly specified result sets. The index returns relevance-ranked top-N. “All 47 unread messages from finance, oldest first” is a query, not a search.</li>
  <li><strong>Auditability.</strong> Every call lands in Application Insights with its exact Graph path and result code, so “did it actually run” is answerable. With native grounding it isn’t, which is why a connector that’s merely not selected looks identical to one that’s broken.</li>
</ul>

<p>The honest counterweight: Copilot on its own needs no Entra app, no on-behalf-of chain, no container, and no per-user connection step. Inside the overlap this is more moving parts for a worse answer. The case rests on the non-overlapping capabilities, the freshness and determinism properties, and the fact that the same index drives the Power Automate connector, where none of Copilot’s grounding exists at all.</p>

<h2 id="deploy-the-federated-head">Deploy the federated head</h2>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">azd</span><span class="w"> </span><span class="nx">env</span><span class="w"> </span><span class="nx">new</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">ENV_NAME</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--location</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">REGION</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--subscription</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">SUBSCRIPTION_ID</span><span class="err">&gt;</span><span class="w">
</span><span class="n">azd</span><span class="w"> </span><span class="nx">env</span><span class="w"> </span><span class="nx">set</span><span class="w"> </span><span class="nx">ENTRA_TENANT_ID</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">TENANT_ID</span><span class="err">&gt;</span><span class="w">
</span><span class="n">azd</span><span class="w"> </span><span class="nx">env</span><span class="w"> </span><span class="nx">set</span><span class="w"> </span><span class="nx">ENTRA_CLIENT_ID</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">SERVER_APP_CLIENT_ID</span><span class="err">&gt;</span><span class="w">
</span><span class="n">azd</span><span class="w"> </span><span class="nx">up</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">azd up</code> provisions a Container App, registry, log workspace, Application Insights, and a user-assigned identity, then builds and pushes the image. Five billable resources, two of which bill whether or not anyone uses the server. <code class="language-plaintext highlighter-rouge">minReplicas: 1</code> is deliberate, because a cold start would land inside Copilot’s request timeout. Set it to <code class="language-plaintext highlighter-rouge">0</code> in <code class="language-plaintext highlighter-rouge">infra/resources.bicep</code> if you’d rather pay less than answer promptly, and run <code class="language-plaintext highlighter-rouge">azd down</code> when you’re finished evaluating.</p>

<p>There’s no client secret. The server authenticates to Entra with workload identity federation, using the same user-assigned identity that pulls the image, which needs a federated credential on the server app registration:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Issuer</td>
      <td><code class="language-plaintext highlighter-rouge">https://login.microsoftonline.com/&lt;TENANT_ID&gt;/v2.0</code></td>
    </tr>
    <tr>
      <td>Subject</td>
      <td>The identity’s principal ID, not its client ID</td>
    </tr>
    <tr>
      <td>Audience</td>
      <td><code class="language-plaintext highlighter-rouge">api://AzureADTokenExchange</code></td>
    </tr>
  </tbody>
</table>

<p>Entra accepts a federated credential with a wrong issuer, subject, or audience without any error, then fails later at token exchange. Verify it by making a real call, never by the credential saving successfully.</p>

<p>Registration then runs through three portals:</p>

<ol>
  <li><strong>Teams Developer Portal</strong> → <strong>Tools</strong> → <strong>Microsoft Entra SSO client ID registration</strong>. Supply the server app’s client ID, base URL, and scope. The scope must be fully qualified—<code class="language-plaintext highlighter-rouge">api://&lt;Application ID URI&gt;/mcp.access</code>, not the bare <code class="language-plaintext highlighter-rouge">mcp.access</code>. A bare scope name has no resource attached, so Entra resolves it against Microsoft Graph and consent fails with <code class="language-plaintext highlighter-rouge">AADSTS650053</code>.</li>
  <li>On the server app registration, add the returned Application ID URI to <code class="language-plaintext highlighter-rouge">identifierUris</code>, add <code class="language-plaintext highlighter-rouge">https://teams.microsoft.com/api/platform/v1.0/oAuthConsentRedirect</code> as a <strong>Web</strong> redirect URI, and pre-authorize <code class="language-plaintext highlighter-rouge">ab3be6b7-f5df-413d-ac2d-abf1e3fd9c0b</code>, the Microsoft Enterprise token store. Then <code class="language-plaintext highlighter-rouge">azd env set ENTRA_EXTRA_AUDIENCES "&lt;Application ID URI&gt;"</code> and redeploy, because Copilot’s token carries that audience rather than the app’s own <code class="language-plaintext highlighter-rouge">api://</code> URI.</li>
  <li><strong>Microsoft 365 admin center</strong> → <strong>Copilot</strong> → <strong>Connectors</strong> → <strong>Gallery</strong> → <strong>Created by your org</strong> → <strong>Connect to MCP server</strong>. Supply the display name, base URL, and SSO registration ID. Display name caps at 30 characters, which is undocumented and the only field on the form short enough to hit it.</li>
</ol>

<p>Each user then connects the server themselves in <strong>Copilot Chat</strong> → <strong>Settings</strong> → <strong>Sources</strong>. Admin rollout only makes the connector available. Until a user creates the connection, prompts are answered from Copilot’s native M365 data and the connector is never consulted, which looks like a broken connector but is just an unconnected one. Changes take up to 15 minutes to take effect, so an immediate failure isn’t meaningful.</p>

<p>Every user who queries the federated head needs a Microsoft 365 Copilot license. It’s a flat per-user add-on rather than a per-call meter, so query volume costs nothing extra, but a tenant without it can’t use this head at all. Check that first.</p>

<p>Check the billing terms too, before you plan around a flat cost. Microsoft now meters several Copilot services with <a href="https://learn.microsoft.com/en-us/microsoft-365/copilot/usage-based-billing-overview-copilot-credits">Copilot Credits</a>, and a Copilot license doesn’t always cover what a custom agent does. The <a href="https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/work-iq/#access-and-pricing">Work IQ API</a> bills on usage even for licensed users when a custom or third-party agent calls it.</p>

<h2 id="behavior-worth-knowing">Behavior worth knowing</h2>

<ul>
  <li><strong>Pagination.</strong> <code class="language-plaintext highlighter-rouge">launch_graph</code> surfaces <code class="language-plaintext highlighter-rouge">@odata.nextLink</code> as <code class="language-plaintext highlighter-rouge">nextLink</code>. Pass it back as the endpoint to page forward. <code class="language-plaintext highlighter-rouge">fetch_work</code> accepts a continuation in <code class="language-plaintext highlighter-rouge">path</code> and passes it through untouched, since re-applying <code class="language-plaintext highlighter-rouge">$top</code> or <code class="language-plaintext highlighter-rouge">$select</code> would corrupt it.</li>
  <li><strong>Absolute URLs.</strong> Only <code class="language-plaintext highlighter-rouge">https://graph.microsoft.com/v1.0/...</code> is accepted. Following an arbitrary absolute URL would turn the tool into an open proxy for whatever host the caller names.</li>
  <li><strong>Throttling.</strong> Transient responses (429, 503, 504) are retried up to three times, honoring <code class="language-plaintext highlighter-rouge">Retry-After</code> when it’s short enough to be worth waiting for. A longer <code class="language-plaintext highlighter-rouge">Retry-After</code> surfaces as an error instead.</li>
  <li><strong>Directory search.</strong> Graph rejects <code class="language-plaintext highlighter-rouge">$search</code> and <code class="language-plaintext highlighter-rouge">$count</code> on <code class="language-plaintext highlighter-rouge">/users</code> and <code class="language-plaintext highlighter-rouge">/groups</code> unless the request carries <code class="language-plaintext highlighter-rouge">ConsistencyLevel: eventual</code>, and <code class="language-plaintext highlighter-rouge">$search</code> also requires <code class="language-plaintext highlighter-rouge">$count=true</code>. Both are added automatically.</li>
  <li><strong>Response size.</strong> Mail bodies and Teams messages are HTML and can be very large. Summarization strips markup and truncates at 500 characters for bodies, 1000 for text. Set <code class="language-plaintext highlighter-rouge">SummarizeResponses = false</code> to disable.</li>
  <li><strong>Default page size.</strong> Graph defaults to 10 and allows up to 999. The connector injects <code class="language-plaintext highlighter-rouge">$top=25</code> on collection reads when no page size is given.</li>
</ul>

<h2 id="going-past-82-operations">Going past 82 operations</h2>

<p>The connector runs <code class="language-plaintext highlighter-rouge">DiscoveryMode.Static</code> against the embedded index. Graph has no per-resource describe endpoint—<code class="language-plaintext highlighter-rouge">$metadata</code> is one monolithic CSDL document—so <code class="language-plaintext highlighter-rouge">Hybrid</code> has nothing to call.</p>

<p>To reach the rest of Graph, switch to <code class="language-plaintext highlighter-rouge">McpChain</code> against MS Learn, at the cost of an external call on every scan:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">DiscoveryMode</span> <span class="p">=</span> <span class="n">DiscoveryMode</span><span class="p">.</span><span class="n">McpChain</span><span class="p">,</span>
<span class="n">McpChainEndpoint</span> <span class="p">=</span> <span class="s">"https://learn.microsoft.com/api/mcp"</span><span class="p">,</span>
<span class="n">McpChainToolName</span> <span class="p">=</span> <span class="s">"microsoft_docs_search"</span><span class="p">,</span>
<span class="n">McpChainQueryPrefix</span> <span class="p">=</span> <span class="s">"Microsoft Graph"</span><span class="p">,</span>
</code></pre></div></div>

<p>The modes are exclusive rather than additive—<code class="language-plaintext highlighter-rouge">McpChain</code> replaces index search instead of supplementing it.</p>

<p>To add operations to the static index instead, edit <code class="language-plaintext highlighter-rouge">graph-capability-index.json</code> and re-embed it into <code class="language-plaintext highlighter-rouge">script.csx</code>. The <code class="language-plaintext highlighter-rouge">CAPABILITY_INDEX</code> constant is a C# verbatim string, so every <code class="language-plaintext highlighter-rouge">"</code> becomes <code class="language-plaintext highlighter-rouge">""</code>. Do that mechanically:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$json</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="o">.</span><span class="nx">\graph-capability-index.json</span><span class="w"> </span><span class="nt">-Raw</span><span class="w">
</span><span class="nv">$escaped</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$json</span><span class="o">.</span><span class="nf">TrimEnd</span><span class="p">()</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s1">'"'</span><span class="p">,</span><span class="w"> </span><span class="s1">'""'</span><span class="w">
</span></code></pre></div></div>

<p>Both heads and the full readme are in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Graph%20Mission%20Control">SharingIsCaring repository</a>.</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Microsoft Graph" /><category term="MCP" /><category term="Model Context Protocol" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="Power Automate" /><category term="Microsoft 365 Copilot" /><category term="Mission Control" /><summary type="html"><![CDATA[A custom MCP connector that reaches 82 Microsoft Graph operations through scan, launch, and sequence, plus a federated MCP server that surfaces the read-only half inside Microsoft 365 Copilot.]]></summary></entry><entry><title type="html">Power Mission Control Template now speaks MCP 2026-07-28 in both directions</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-31-power-mission-control-template-mcp-2026-07-28.html" rel="alternate" type="text/html" title="Power Mission Control Template now speaks MCP 2026-07-28 in both directions" /><published>2026-07-31T16:00:00+00:00</published><updated>2026-07-31T16:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/power-mission-control-template-mcp-2026-07-28</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-31-power-mission-control-template-mcp-2026-07-28.html"><![CDATA[<p>The <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Power%20Mission%20Control%20Template">Power Mission Control Template</a> trades dozens of typed tools for three—<code class="language-plaintext highlighter-rouge">scan</code>, <code class="language-plaintext highlighter-rouge">launch</code>, and <code class="language-plaintext highlighter-rouge">sequence</code>—so a Copilot Studio agent discovers API operations on demand instead of loading every schema upfront. That part hasn’t changed. What changed is the protocol underneath it.</p>

<p>The template now runs on MCP <code class="language-plaintext highlighter-rouge">2026-07-28</code>, and mission control is the one connector where that revision matters twice. In <code class="language-plaintext highlighter-rouge">McpChain</code> discovery mode it’s both an MCP server and an MCP client, so it has to negotiate protocol eras coming in and going out.</p>

<p>I covered the pattern itself in <a href="/power%20platform/custom%20connectors/2026-03-23-power-mission-control-mcp-template.html">the original Power Mission Control post</a>, and the protocol changes in <a href="/power%20platform/custom%20connectors/2026-07-31-power-mcp-template-mcp-2026-07-28.html">the Power MCP Template update</a>. This covers what mission control adds on top.</p>

<h2 id="serving-both-eras">Serving both eras</h2>

<p>As a server, mission control behaves like the base template. A request carrying a <code class="language-plaintext highlighter-rouge">_meta</code> protocol version, an <code class="language-plaintext highlighter-rouge">MCP-Protocol-Version</code> header, or a call to <code class="language-plaintext highlighter-rouge">server/discover</code> is served modern. Everything else, Copilot Studio included, is served exactly as before.</p>

<p>The three tools are stable by design, which makes them worth caching:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ProtocolVersion</span> <span class="p">=</span> <span class="s">"2026-07-28"</span><span class="p">,</span>
<span class="n">SupportedProtocolVersions</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="p">{</span> <span class="s">"2026-07-28"</span><span class="p">,</span> <span class="s">"2025-11-25"</span><span class="p">,</span> <span class="s">"2025-06-18"</span> <span class="p">},</span>

<span class="n">ListCacheTtlMs</span> <span class="p">=</span> <span class="m">900000</span><span class="p">,</span>   <span class="c1">// 15 min — the three tools are stable</span>
<span class="n">ListCacheScope</span> <span class="p">=</span> <span class="s">"public"</span><span class="p">,</span>
<span class="n">DiscoverCacheTtlMs</span> <span class="p">=</span> <span class="m">3600000</span><span class="p">,</span>

<span class="n">Instructions</span> <span class="p">=</span> <span class="s">"Call scan_{service} first to find the operation you need, then launch_{service} to execute it."</span>
</code></pre></div></div>

<p>Fill in <code class="language-plaintext highlighter-rouge">Instructions</code>. <code class="language-plaintext highlighter-rouge">server/discover</code> returns it, and it’s the one place you can tell the model how to drive three generic tools before it has called anything. Without it, the planner has to infer the scan-then-launch sequence from tool descriptions alone.</p>

<p>The capability catalog is also deterministically ordered now, so a client can hold it across reconnects rather than re-fetching.</p>

<h2 id="calling-out-as-a-client">Calling out as a client</h2>

<p><code class="language-plaintext highlighter-rouge">McpChain</code> mode routes a <code class="language-plaintext highlighter-rouge">scan</code> query to an external MCP server and parses the documentation results. That external server has its own protocol era, and the connector has to figure out which one.</p>

<p><code class="language-plaintext highlighter-rouge">McpChainClient</code> tries the modern path first:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant MC as Mission Control
    participant Up as Upstream MCP server

    MC-&gt;&gt;Up: tools/call with _meta protocol version
    alt Modern upstream
        Up--&gt;&gt;MC: result
        Note over MC: One round trip. Era cached.
    else Legacy upstream
        Up--&gt;&gt;MC: 4xx
        MC-&gt;&gt;Up: initialize
        Up--&gt;&gt;MC: capabilities
        MC-&gt;&gt;Up: notifications/initialized
        MC-&gt;&gt;Up: tools/call
        Up--&gt;&gt;MC: result
        Note over MC: Era cached, handshake paid once.
    end
</code></pre>

<p>Two things fall out of this.</p>

<p>The happy path drops from three HTTP round trips to one, inside a code path that already needed a cache because it was slow. And the era is cached per endpoint, so the probe is paid once rather than on every scan.</p>

<p>The fallback logic reads the error rather than the status alone. A recognized modern error—<code class="language-plaintext highlighter-rouge">-32020</code>, <code class="language-plaintext highlighter-rouge">-32021</code>, or <code class="language-plaintext highlighter-rouge">-32022</code>—means the upstream is modern and refused that specific request, so the client doesn’t fall back and retry as legacy. Any other <code class="language-plaintext highlighter-rouge">4xx</code> identifies a legacy server.</p>

<p>This mattered immediately. The documented default <code class="language-plaintext highlighter-rouge">McpChainEndpoint</code> is MS Learn MCP, and Microsoft shipped <code class="language-plaintext highlighter-rouge">2026-07-28</code> support on day zero. Under the old client code, a modern upstream would have broken <code class="language-plaintext highlighter-rouge">McpChain</code> discovery outright.</p>

<h2 id="confirming-a-destructive-launch">Confirming a destructive launch</h2>

<p><code class="language-plaintext highlighter-rouge">launch</code> and <code class="language-plaintext highlighter-rouge">sequence</code> execute whatever endpoint and method the planner hands them, including <code class="language-plaintext highlighter-rouge">DELETE</code>. Multi Round-Trip Requests let a stateless connector ask before acting:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">handler</span><span class="p">.</span><span class="nf">AddTool</span><span class="p">(</span><span class="s">"launch_guarded"</span><span class="p">,</span> <span class="s">"Launch an operation, confirming destructive methods first."</span><span class="p">,</span>
    <span class="n">schema</span><span class="p">:</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span>
        <span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">"endpoint"</span><span class="p">,</span> <span class="s">"API endpoint path"</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="k">true</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">"method"</span><span class="p">,</span> <span class="s">"HTTP method"</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="k">true</span><span class="p">),</span>
    <span class="n">handler</span><span class="p">:</span> <span class="k">async</span> <span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="n">ct</span><span class="p">)</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">method</span> <span class="p">=</span> <span class="n">args</span><span class="p">.</span><span class="n">Value</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="s">"method"</span><span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">method</span> <span class="p">==</span> <span class="s">"DELETE"</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="n">McpRequestHandler</span><span class="p">.</span><span class="nf">InputRequired</span><span class="p">(</span>
                <span class="k">new</span> <span class="n">JObject</span>
                <span class="p">{</span>
                    <span class="p">[</span><span class="s">"confirm"</span><span class="p">]</span> <span class="p">=</span> <span class="n">McpRequestHandler</span><span class="p">.</span><span class="nf">ElicitationRequest</span><span class="p">(</span>
                        <span class="s">$"Confirm </span><span class="p">{</span><span class="n">method</span><span class="p">}</span><span class="s"> on </span><span class="p">{</span><span class="n">args</span><span class="p">.</span><span class="n">Value</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="s">"endpoint"</span><span class="p">)}</span><span class="s">?"</span><span class="p">,</span>
                        <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="nf">Boolean</span><span class="p">(</span><span class="s">"proceed"</span><span class="p">,</span> <span class="s">"Confirm"</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="k">true</span><span class="p">))</span>
                <span class="p">},</span>
                <span class="n">requestState</span><span class="p">:</span> <span class="n">args</span><span class="p">.</span><span class="n">Value</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="s">"endpoint"</span><span class="p">));</span>
        <span class="p">}</span>
        <span class="c1">// Execute</span>
    <span class="p">});</span>
</code></pre></div></div>

<p>Legacy clients can’t parse <code class="language-plaintext highlighter-rouge">input_required</code>, so the framework degrades it to a tool error naming the confirmation they can’t supply. Nothing executes unconfirmed either way.</p>

<h2 id="mission-control-or-typed-tools">Mission control or typed tools</h2>

<p>The readme’s comparison table used to read like a version history—v2 versus v3. Both patterns ship on the same framework, so the comparison is now between two approaches you choose per connector.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Typed tools</th>
      <th>Mission control</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pattern</td>
      <td>One <code class="language-plaintext highlighter-rouge">AddTool()</code> per API operation</td>
      <td>Three tools: scan, launch, sequence</td>
    </tr>
    <tr>
      <td>Tool count</td>
      <td>Grows with API surface (10–50+)</td>
      <td>Fixed at three, plus optional custom tools</td>
    </tr>
    <tr>
      <td>Discovery</td>
      <td><code class="language-plaintext highlighter-rouge">tools/list</code> dumps all schemas upfront</td>
      <td>Progressive—the planner scans first</td>
    </tr>
    <tr>
      <td>Schema delivery</td>
      <td>Full JSON Schema per tool, always loaded</td>
      <td>On demand via <code class="language-plaintext highlighter-rouge">include_schema=true</code></td>
    </tr>
    <tr>
      <td>API coverage</td>
      <td>Only operations you explicitly register</td>
      <td>Any endpoint via generic <code class="language-plaintext highlighter-rouge">launch</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tools/list</code> payload (30 operations)</td>
      <td>~15,000 tokens</td>
      <td>~1,500 tokens</td>
    </tr>
    <tr>
      <td>Per-interaction cost</td>
      <td>0, schemas are pre-loaded</td>
      <td>~200–400 for the scan call</td>
    </tr>
  </tbody>
</table>

<p>Typed tools win for a small fixed set of operations, or where each one needs unique logic that doesn’t fit a generic proxy. Mission control wins past roughly ten operations, or when the API will keep growing—adding an operation means adding an index entry, not writing code.</p>

<p>You can mix them. <code class="language-plaintext highlighter-rouge">AddTool()</code>, <code class="language-plaintext highlighter-rouge">AddResource()</code>, <code class="language-plaintext highlighter-rouge">AddResourceTemplate()</code>, <code class="language-plaintext highlighter-rouge">AddPrompt()</code>, and <code class="language-plaintext highlighter-rouge">AddSkill()</code> all work alongside mission control mode, so high-value operations can be exposed directly while the generic tools cover the rest.</p>

<p><code class="language-plaintext highlighter-rouge">AddSkill()</code> is a good fit here. Publishing a skill that documents how to drive scan, launch, and sequence gives Microsoft Agent Framework agents the same guidance <code class="language-plaintext highlighter-rouge">Instructions</code> gives the Copilot Studio planner.</p>

<h2 id="also-in-this-revision">Also in this revision</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Mcp-Method</code> and <code class="language-plaintext highlighter-rouge">Mcp-Name</code> headers are sent on outbound calls and validated on inbound ones, with <code class="language-plaintext highlighter-rouge">HeaderMismatch</code> (<code class="language-plaintext highlighter-rouge">-32020</code>) when a header disagrees with the body</li>
  <li>Error codes <code class="language-plaintext highlighter-rouge">-32020</code>, <code class="language-plaintext highlighter-rouge">-32021</code>, and <code class="language-plaintext highlighter-rouge">-32022</code>; resource-not-found moved from <code class="language-plaintext highlighter-rouge">-32002</code> to <code class="language-plaintext highlighter-rouge">-32602</code></li>
  <li><code class="language-plaintext highlighter-rouge">ResourceLinkContent</code>, and <code class="language-plaintext highlighter-rouge">structuredContent</code> now accepts any JSON value</li>
  <li>The schema builder is hardened against the four documented Copilot Studio schema defects, including <code class="language-plaintext highlighter-rouge">$ref</code> silently dropping a tool from <code class="language-plaintext highlighter-rouge">tools/list</code></li>
  <li><code class="language-plaintext highlighter-rouge">AddTool</code> declared <code class="language-plaintext highlighter-rouge">schemaConfig</code> and <code class="language-plaintext highlighter-rouge">annotationsConfig</code> while every call site used <code class="language-plaintext highlighter-rouge">schema:</code> and <code class="language-plaintext highlighter-rouge">annotations:</code>. The parameters now match the call sites.</li>
</ul>

<h2 id="getting-started">Getting started</h2>

<p>Set <code class="language-plaintext highlighter-rouge">ServiceName</code> and <code class="language-plaintext highlighter-rouge">BaseApiUrl</code> in <code class="language-plaintext highlighter-rouge">MissionControlOptions</code>, pick a discovery mode, then build the capability index with the companion <code class="language-plaintext highlighter-rouge">generate-capability-index.prompt.md</code>—paste your API documentation, and Copilot generates the JSON array to drop into <code class="language-plaintext highlighter-rouge">CAPABILITY_INDEX</code>.</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">MissionControlOptions</span> <span class="n">McOptions</span> <span class="p">=</span> <span class="k">new</span> <span class="n">MissionControlOptions</span>
<span class="p">{</span>
    <span class="n">ServiceName</span> <span class="p">=</span> <span class="s">"salesforce"</span><span class="p">,</span>
    <span class="n">BaseApiUrl</span> <span class="p">=</span> <span class="s">"https://your-instance.salesforce.com/services/data/v66.0"</span><span class="p">,</span>
    <span class="n">DiscoveryMode</span> <span class="p">=</span> <span class="n">DiscoveryMode</span><span class="p">.</span><span class="n">Static</span><span class="p">,</span>
    <span class="n">MaxDiscoverResults</span> <span class="p">=</span> <span class="m">3</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Deploy as a custom connector and add it to an agent with <a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-generative-actions">generative orchestration</a> enabled.</p>

<p>The full template is in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Power%20Mission%20Control%20Template">SharingIsCaring repository</a>.</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Power Mission Control" /><category term="MCP" /><category term="Model Context Protocol" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="Progressive Discovery" /><category term="Templates" /><summary type="html"><![CDATA[The Power Mission Control Template implements MCP 2026-07-28 as both a server and a client. Dual-era McpChain negotiation, cacheable tool catalogs, and confirmation before a destructive launch.]]></summary></entry><entry><title type="html">Power MCP Template now speaks MCP 2026-07-28</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-31-power-mcp-template-mcp-2026-07-28.html" rel="alternate" type="text/html" title="Power MCP Template now speaks MCP 2026-07-28" /><published>2026-07-31T14:00:00+00:00</published><updated>2026-07-31T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/power-mcp-template-mcp-2026-07-28</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-31-power-mcp-template-mcp-2026-07-28.html"><![CDATA[<p>Every previous release of the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Power%20MCP%20Template">Power MCP Template</a> carried a section called <strong>Stateless limitations</strong>, listing the parts of MCP a Power Platform custom connector couldn’t do. That section is gone.</p>

<p>MCP <code class="language-plaintext highlighter-rouge">2026-07-28</code> removed the <code class="language-plaintext highlighter-rouge">initialize</code> handshake, deleted the <code class="language-plaintext highlighter-rouge">Mcp-Session-Id</code> header, and made the protocol stateless. Every request now carries its own protocol version, client identity, and capabilities in <code class="language-plaintext highlighter-rouge">_meta</code>. A custom connector is a stateless request-in, response-out function, so the runtime and the protocol finally agree on the same execution model.</p>

<p>The template ships as <code class="language-plaintext highlighter-rouge">script.csx</code>, <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code>, <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, a <code class="language-plaintext highlighter-rouge">copilot-instructions.md</code> for agents editing the code, and a readme. You write tools, resources, prompts, and skills; the built-in <code class="language-plaintext highlighter-rouge">McpRequestHandler</code> handles the protocol.</p>

<h2 id="what-the-protocol-change-fixed">What the protocol change fixed</h2>

<table>
  <thead>
    <tr>
      <th>Old limitation</th>
      <th>Where it went</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Session state across calls</td>
      <td>The protocol has no sessions. State travels as explicit handles in tool arguments.</td>
    </tr>
    <tr>
      <td>Elicitation, or asking the user mid-call</td>
      <td>Multi Round-Trip Requests. The server returns <code class="language-plaintext highlighter-rouge">input_required</code> and the client retries with answers.</td>
    </tr>
    <tr>
      <td>Version negotiation without a handshake</td>
      <td>Per-request <code class="language-plaintext highlighter-rouge">_meta</code>, plus a <code class="language-plaintext highlighter-rouge">server/discover</code> RPC.</td>
    </tr>
    <tr>
      <td>Cache-friendly tool catalogs</td>
      <td><code class="language-plaintext highlighter-rouge">ttlMs</code> and <code class="language-plaintext highlighter-rouge">cacheScope</code> on every list result, with deterministic ordering.</td>
    </tr>
  </tbody>
</table>

<p>Two things stay out of reach, both because a connector can’t hold a stream open: the <code class="language-plaintext highlighter-rouge">subscriptions/listen</code> notification stream, and the <code class="language-plaintext highlighter-rouge">io.modelcontextprotocol/tasks</code> extension.</p>

<h2 id="dual-era-serving">Dual-era serving</h2>

<p>Copilot Studio is still a legacy client. It opens with <code class="language-plaintext highlighter-rouge">initialize</code> and knows nothing about <code class="language-plaintext highlighter-rouge">_meta</code>, <code class="language-plaintext highlighter-rouge">server/discover</code>, or <code class="language-plaintext highlighter-rouge">resultType</code>. The spec permits one server to answer both eras on a single endpoint, so the template does.</p>

<p>The era is decided per request:</p>

<table>
  <thead>
    <tr>
      <th>Signal</th>
      <th>Era</th>
      <th>Behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">params._meta["io.modelcontextprotocol/protocolVersion"]</code></td>
      <td>modern</td>
      <td>Full 2026-07-28 semantics</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MCP-Protocol-Version</code> header</td>
      <td>modern</td>
      <td>Same</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">method: "server/discover"</code></td>
      <td>modern</td>
      <td>Answered regardless</td>
    </tr>
    <tr>
      <td>None of the above</td>
      <td>legacy</td>
      <td>Byte-identical to the previous template</td>
    </tr>
  </tbody>
</table>

<p>Modern-only fields—<code class="language-plaintext highlighter-rouge">resultType</code>, <code class="language-plaintext highlighter-rouge">ttlMs</code>, <code class="language-plaintext highlighter-rouge">cacheScope</code>, and <code class="language-plaintext highlighter-rouge">_meta.serverInfo</code>—never appear in a legacy response. That’s what makes the upgrade safe to deploy against a Copilot Studio agent that’s already running.</p>

<h2 id="configuring-the-server">Configuring the server</h2>

<p><code class="language-plaintext highlighter-rouge">McpServerOptions</code> at the top of the script drives <code class="language-plaintext highlighter-rouge">server/discover</code>, the legacy <code class="language-plaintext highlighter-rouge">initialize</code>, and the cache hints:</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">McpServerOptions</span> <span class="n">Options</span> <span class="p">=</span> <span class="k">new</span> <span class="n">McpServerOptions</span>
<span class="p">{</span>
    <span class="n">ServerInfo</span> <span class="p">=</span> <span class="k">new</span> <span class="n">McpServerInfo</span>
    <span class="p">{</span>
        <span class="n">Name</span> <span class="p">=</span> <span class="s">"power-mcp-server"</span><span class="p">,</span>
        <span class="n">Version</span> <span class="p">=</span> <span class="s">"1.0.0"</span><span class="p">,</span>
        <span class="n">Title</span> <span class="p">=</span> <span class="s">"Power MCP Server"</span><span class="p">,</span>
        <span class="n">Description</span> <span class="p">=</span> <span class="s">"Power Platform custom connector implementing Model Context Protocol"</span>
    <span class="p">},</span>

    <span class="n">ProtocolVersion</span> <span class="p">=</span> <span class="s">"2026-07-28"</span><span class="p">,</span>
    <span class="n">SupportedProtocolVersions</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="p">{</span> <span class="s">"2026-07-28"</span><span class="p">,</span> <span class="s">"2025-11-25"</span><span class="p">,</span> <span class="s">"2025-06-18"</span> <span class="p">},</span>

    <span class="n">Capabilities</span> <span class="p">=</span> <span class="k">new</span> <span class="n">McpCapabilities</span>
    <span class="p">{</span>
        <span class="n">Tools</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
        <span class="n">Resources</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
        <span class="n">Prompts</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
        <span class="n">Completions</span> <span class="p">=</span> <span class="k">true</span>
    <span class="p">},</span>

    <span class="n">ListCacheTtlMs</span> <span class="p">=</span> <span class="m">300000</span><span class="p">,</span>
    <span class="n">ListCacheScope</span> <span class="p">=</span> <span class="s">"public"</span><span class="p">,</span>
    <span class="n">ResourceCacheTtlMs</span> <span class="p">=</span> <span class="m">60000</span><span class="p">,</span>
    <span class="n">ResourceCacheScope</span> <span class="p">=</span> <span class="s">"private"</span><span class="p">,</span>
    <span class="n">DiscoverCacheTtlMs</span> <span class="p">=</span> <span class="m">3600000</span><span class="p">,</span>

    <span class="n">Instructions</span> <span class="p">=</span> <span class="s">""</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Drop a revision from <code class="language-plaintext highlighter-rouge">SupportedProtocolVersions</code> and the server returns <code class="language-plaintext highlighter-rouge">UnsupportedProtocolVersionError</code> (<code class="language-plaintext highlighter-rouge">-32022</code>) with the list it does support, so the client can retry.</p>

<p><code class="language-plaintext highlighter-rouge">server/discover</code> is mandatory for servers on <code class="language-plaintext highlighter-rouge">2026-07-28</code>, and the framework generates it from these options. There’s nothing to implement.</p>

<h2 id="cache-scope-is-a-security-decision">Cache scope is a security decision</h2>

<p><code class="language-plaintext highlighter-rouge">2026-07-28</code> requires cache hints on every <code class="language-plaintext highlighter-rouge">complete</code> result from <code class="language-plaintext highlighter-rouge">server/discover</code>, the four list methods, and <code class="language-plaintext highlighter-rouge">resources/read</code>. The framework attaches them; you choose the values.</p>

<p><code class="language-plaintext highlighter-rouge">public</code> permits a shared gateway to serve one caller’s cached response to another. Use <code class="language-plaintext highlighter-rouge">private</code> whenever the result depends on who’s asking, which is why <code class="language-plaintext highlighter-rouge">resources/read</code> defaults to it. Tool and prompt lists default to <code class="language-plaintext highlighter-rouge">public</code> because they’re normally identical for everyone. If your <code class="language-plaintext highlighter-rouge">tools/list</code> varies by the caller’s granted scopes, change <code class="language-plaintext highlighter-rouge">ListCacheScope</code> to <code class="language-plaintext highlighter-rouge">private</code>.</p>

<p>Tool ordering is deterministic (registration order), which is what makes list caching and upstream prompt caching worth anything.</p>

<h2 id="multi-round-trip-requests">Multi Round-Trip Requests</h2>

<p>MRTR is how <code class="language-plaintext highlighter-rouge">2026-07-28</code> replaced server-initiated <code class="language-plaintext highlighter-rouge">elicitation/create</code>, <code class="language-plaintext highlighter-rouge">sampling/createMessage</code>, and <code class="language-plaintext highlighter-rouge">roots/list</code>. Rather than pushing a request down an open stream, the server returns <code class="language-plaintext highlighter-rouge">resultType: "input_required"</code> and the client retries the original call with the answers attached.</p>

<p>That inversion is what makes elicitation possible from a connector. There’s no stream to hold open and no session to keep, just two ordinary request and response exchanges.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant Client as MCP client
    participant Conn as Connector

    Client-&gt;&gt;Conn: tools/call delete_records { filter }
    Conn--&gt;&gt;Client: input_required + elicitation + requestState
    Note over Client: Asks the user to confirm
    Client-&gt;&gt;Conn: tools/call delete_records { inputResponses, requestState }
    Conn--&gt;&gt;Client: complete
</code></pre>

<p>Ask for the confirmation and stash what you need to resume:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">handler</span><span class="p">.</span><span class="nf">AddTool</span><span class="p">(</span><span class="s">"delete_records"</span><span class="p">,</span> <span class="s">"Permanently delete matching records."</span><span class="p">,</span>
    <span class="n">schema</span><span class="p">:</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">"filter"</span><span class="p">,</span> <span class="s">"Records to delete"</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="k">true</span><span class="p">),</span>
    <span class="n">handler</span><span class="p">:</span> <span class="k">async</span> <span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="n">ct</span><span class="p">)</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">filter</span> <span class="p">=</span> <span class="n">args</span><span class="p">.</span><span class="n">Value</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="s">"filter"</span><span class="p">);</span>

        <span class="k">return</span> <span class="n">McpRequestHandler</span><span class="p">.</span><span class="nf">InputRequired</span><span class="p">(</span>
            <span class="k">new</span> <span class="n">JObject</span>
            <span class="p">{</span>
                <span class="p">[</span><span class="s">"confirm"</span><span class="p">]</span> <span class="p">=</span> <span class="n">McpRequestHandler</span><span class="p">.</span><span class="nf">ElicitationRequest</span><span class="p">(</span>
                    <span class="s">$"Permanently delete all records matching '</span><span class="p">{</span><span class="n">filter</span><span class="p">}</span><span class="s">'?"</span><span class="p">,</span>
                    <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="nf">Boolean</span><span class="p">(</span><span class="s">"proceed"</span><span class="p">,</span> <span class="s">"Confirm deletion"</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="k">true</span><span class="p">))</span>
            <span class="p">},</span>
            <span class="n">requestState</span><span class="p">:</span> <span class="n">filter</span><span class="p">);</span>
    <span class="p">});</span>
</code></pre></div></div>

<p>On the retry, <code class="language-plaintext highlighter-rouge">inputResponses</code> and <code class="language-plaintext highlighter-rouge">requestState</code> arrive on the request context. Because the server holds nothing between the two calls, anything you need to resume has to go in <code class="language-plaintext highlighter-rouge">requestState</code>. Treat it as opaque to the client, and keep secrets out of it—it round-trips through the caller.</p>

<p>Legacy clients can’t interpret <code class="language-plaintext highlighter-rouge">resultType</code>. Instead of emitting a shape Copilot Studio would fail to parse, the framework converts an <code class="language-plaintext highlighter-rouge">input_required</code> into a tool error naming the missing input, so the model asks for it as a normal argument. One code path, sensible degradation.</p>

<h2 id="agent-skills-over-mcp">Agent Skills over MCP</h2>

<p>An <a href="https://agentskills.io/">Agent Skill</a> is a portable package of instructions and resources that an agent loads on demand. <code class="language-plaintext highlighter-rouge">AddSkill()</code> publishes one over MCP, so a connector becomes a distribution point for skills that <a href="https://learn.microsoft.com/en-us/agent-framework/agents/skills?pivots=programming-language-csharp#mcp-based-skills">Microsoft Agent Framework</a> agents and Microsoft Foundry Toolbox pull at runtime. This adds no protocol surface—skills are ordinary MCP resources under the <code class="language-plaintext highlighter-rouge">skill://</code> scheme.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">handler</span><span class="p">.</span><span class="nf">AddSkill</span><span class="p">(</span><span class="s">"expense-report"</span><span class="p">,</span>
    <span class="s">"File and validate employee expense reports according to company policy. "</span> <span class="p">+</span>
    <span class="s">"Use when asked about expense submissions, reimbursement rules, or spending limits."</span><span class="p">,</span>
    <span class="n">instructions</span><span class="p">:</span> <span class="s">@"1. Read the `policy-limits` resource for current per-category caps.
2. Validate each line item against those caps.
3. Flag any item that exceeds a cap and state which rule it breaks."</span><span class="p">,</span>
    <span class="n">resources</span><span class="p">:</span> <span class="n">r</span> <span class="p">=&gt;</span> <span class="n">r</span>
        <span class="p">.</span><span class="nf">Resource</span><span class="p">(</span><span class="s">"references/policy-limits.md"</span><span class="p">,</span> <span class="s">"Per-category spending caps."</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="n">limitsMarkdown</span><span class="p">),</span>
    <span class="n">license</span><span class="p">:</span> <span class="s">"Apache-2.0"</span><span class="p">);</span>
</code></pre></div></div>

<p>That one call generates <code class="language-plaintext highlighter-rouge">skill://index.json</code>, <code class="language-plaintext highlighter-rouge">skill://expense-report/SKILL.md</code>, and a resource per sibling file. Every naming and length constraint is checked at registration, so a malformed skill throws when the connector loads instead of failing quietly at discovery time.</p>

<p>Because the skill lives on the server, editing the instructions updates every connected agent without redeploying any of them.</p>

<h2 id="the-schema-builder-stays-narrower-than-the-spec">The schema builder stays narrower than the spec</h2>

<p><code class="language-plaintext highlighter-rouge">2026-07-28</code> loosened <code class="language-plaintext highlighter-rouge">inputSchema</code> and <code class="language-plaintext highlighter-rouge">outputSchema</code> to allow any JSON Schema 2020-12 keyword, including <code class="language-plaintext highlighter-rouge">$ref</code> and <code class="language-plaintext highlighter-rouge">$defs</code>. <code class="language-plaintext highlighter-rouge">McpSchemaBuilder</code> doesn’t use them, and neither should a schema you hand-write.</p>

<p>The reason is Copilot Studio. Its <a href="https://learn.microsoft.com/microsoft-copilot-studio/mcp-troubleshooting">documented MCP limitations</a> include four schema behaviors that are easy to trip over:</p>

<table>
  <thead>
    <tr>
      <th>Schema construct</th>
      <th>What Copilot Studio does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">$ref</code> or any reference-type input</td>
      <td>Drops the tool from <code class="language-plaintext highlighter-rouge">tools/list</code>, silently</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"type": ["string", "null"]</code></td>
      <td>Truncates the schema</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">exclusiveMinimum</code> as a number (2020-12 form)</td>
      <td>Throws <code class="language-plaintext highlighter-rouge">System.FormatException</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enum</code></td>
      <td>Accepts it, but treats the parameter as a plain string</td>
    </tr>
  </tbody>
</table>

<p>A dropped tool produces no error anywhere. It simply never appears. The builder inlines nested objects rather than referencing them and never emits a multi-type array. If you add numeric bounds, use the draft-04 boolean form of <code class="language-plaintext highlighter-rouge">exclusiveMinimum</code>.</p>

<h2 id="new-error-codes">New error codes</h2>

<p><code class="language-plaintext highlighter-rouge">2026-07-28</code> partitioned the JSON-RPC implementation-defined range. <code class="language-plaintext highlighter-rouge">-32000</code> to <code class="language-plaintext highlighter-rouge">-32019</code> is legacy and frozen; <code class="language-plaintext highlighter-rouge">-32020</code> to <code class="language-plaintext highlighter-rouge">-32099</code> belongs to the specification.</p>

<table>
  <thead>
    <tr>
      <th>Code</th>
      <th>Name</th>
      <th>When</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-32020</code></td>
      <td><code class="language-plaintext highlighter-rouge">HeaderMismatch</code></td>
      <td><code class="language-plaintext highlighter-rouge">Mcp-Method</code> or <code class="language-plaintext highlighter-rouge">Mcp-Name</code> disagrees with the body</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-32021</code></td>
      <td><code class="language-plaintext highlighter-rouge">MissingRequiredClientCapability</code></td>
      <td>Client didn’t declare a capability the request needs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-32022</code></td>
      <td><code class="language-plaintext highlighter-rouge">UnsupportedProtocolVersion</code></td>
      <td>Requested revision isn’t in <code class="language-plaintext highlighter-rouge">SupportedProtocolVersions</code></td>
    </tr>
  </tbody>
</table>

<p>Resource-not-found also moved from <code class="language-plaintext highlighter-rouge">-32002</code> to <code class="language-plaintext highlighter-rouge">-32602</code>.</p>

<h2 id="methods-removed-in-this-revision">Methods removed in this revision</h2>

<p>A modern caller that invokes <code class="language-plaintext highlighter-rouge">initialize</code>, <code class="language-plaintext highlighter-rouge">notifications/initialized</code>, <code class="language-plaintext highlighter-rouge">notifications/roots/list_changed</code>, <code class="language-plaintext highlighter-rouge">ping</code>, <code class="language-plaintext highlighter-rouge">logging/setLevel</code>, <code class="language-plaintext highlighter-rouge">resources/subscribe</code>, or <code class="language-plaintext highlighter-rouge">resources/unsubscribe</code> gets <code class="language-plaintext highlighter-rouge">MethodNotFound</code>. A legacy caller gets the same behavior it always did. Don’t delete the legacy branch—Copilot Studio depends on it.</p>

<h2 id="wiring-it-up">Wiring it up</h2>

<p><code class="language-plaintext highlighter-rouge">ExecuteAsync</code> now passes the request through so the framework can read the transport headers:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">HttpResponseMessage</span><span class="p">&gt;</span> <span class="nf">ExecuteAsync</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">handler</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">McpRequestHandler</span><span class="p">(</span><span class="n">Options</span><span class="p">);</span>
    <span class="nf">RegisterCapabilities</span><span class="p">(</span><span class="n">handler</span><span class="p">);</span>

    <span class="kt">var</span> <span class="n">body</span> <span class="p">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="n">Context</span><span class="p">.</span><span class="n">Request</span><span class="p">.</span><span class="n">Content</span><span class="p">.</span><span class="nf">ReadAsStringAsync</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">result</span> <span class="p">=</span> <span class="k">await</span> <span class="n">handler</span><span class="p">.</span><span class="nf">HandleAsync</span><span class="p">(</span>
        <span class="n">body</span><span class="p">,</span>
        <span class="n">McpTransportHeaders</span><span class="p">.</span><span class="nf">FromRequest</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="n">Context</span><span class="p">.</span><span class="n">Request</span><span class="p">),</span>
        <span class="k">this</span><span class="p">.</span><span class="n">CancellationToken</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="k">return</span> <span class="k">new</span> <span class="nf">HttpResponseMessage</span><span class="p">(</span><span class="n">HttpStatusCode</span><span class="p">.</span><span class="n">OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">Content</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StringContent</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="n">Encoding</span><span class="p">.</span><span class="n">UTF8</span><span class="p">,</span> <span class="s">"application/json"</span><span class="p">)</span>
    <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The connector files ship ready to deploy once you set <code class="language-plaintext highlighter-rouge">host</code>. The Swagger is a single <code class="language-plaintext highlighter-rouge">POST</code> with <code class="language-plaintext highlighter-rouge">basePath</code> of <code class="language-plaintext highlighter-rouge">/mcp</code> and the operation at <code class="language-plaintext highlighter-rouge">/</code>, carrying <code class="language-plaintext highlighter-rouge">x-ms-agentic-protocol: mcp-streamable-1.0</code>. That operation must have no <code class="language-plaintext highlighter-rouge">parameters</code> key at all, not even an empty array—Power Platform injects its own body parameter and a declared one collides with 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">--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.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></code></pre></div></div>

<h2 id="upgrading-from-v2">Upgrading from v2</h2>

<p>The script sections swapped order. Section 1 is now the connector entry point—server options, <code class="language-plaintext highlighter-rouge">RegisterCapabilities</code>, <code class="language-plaintext highlighter-rouge">ExecuteAsync</code>, and optional Application Insights logging. Section 2 is the framework, which you leave alone unless you’re extending it.</p>

<p>One breaking rename came with the revision: <code class="language-plaintext highlighter-rouge">AddTool</code> declared <code class="language-plaintext highlighter-rouge">schemaConfig</code> and <code class="language-plaintext highlighter-rouge">annotationsConfig</code>, while every call site and both doc files used <code class="language-plaintext highlighter-rouge">schema:</code> and <code class="language-plaintext highlighter-rouge">annotations:</code>. The parameters are now named <code class="language-plaintext highlighter-rouge">schema</code> and <code class="language-plaintext highlighter-rouge">annotations</code>, matching what everything already called them.</p>

<p>If you’re new to the template, start with the <a href="/power%20platform/custom%20connectors/2026-01-20-power-mcp-custom-connector.html">original Power MCP post</a> for the architecture, then <a href="/power%20platform/custom%20connectors/2026-02-18-power-mcp-template-v2.html">v2</a> for the fluent registration API.</p>

<p>The full template is in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Power%20MCP%20Template">SharingIsCaring repository</a>.</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Power MCP" /><category term="MCP" /><category term="Model Context Protocol" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="Agent Skills" /><category term="Templates" /><summary type="html"><![CDATA[The Power MCP Template implements MCP 2026-07-28 and serves both protocol eras from one endpoint. Stateless protocol, server/discover, cache hints, Multi Round-Trip Requests, and Agent Skills over MCP.]]></summary></entry><entry><title type="html">Microsoft 365 Copilot usage reports as a Power Platform connector</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-26-copilot-usage-reports-connector.html" rel="alternate" type="text/html" title="Microsoft 365 Copilot usage reports as a Power Platform connector" /><published>2026-07-26T14:00:00+00:00</published><updated>2026-07-26T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/copilot-usage-reports-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-26-copilot-usage-reports-connector.html"><![CDATA[<p>Adoption reporting is where most Copilot rollouts get judged. Who’s using it, in which apps, how often—the answers live in the Microsoft 365 admin center usage report, but pulling that data into a dashboard or an automated review means calling the Graph Copilot usage reports API. This custom MCP connector wraps that API so a Power Automate flow or a Copilot Studio agent can retrieve per-user usage as JSON or CSV.</p>

<p>You can find the complete code in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Usage%20Reports">SharingIsCaring repository</a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>The connector wraps <code class="language-plaintext highlighter-rouge">getMicrosoft365CopilotUsageUserDetail(period, version)</code>. It returns per-user last-activity dates across Teams, Word, Excel, PowerPoint, Outlook, OneNote, Loop, and Copilot Chat, plus prompt counts and agent activity in version 2. Ask for a period, pick a version and format, and the connector returns the report.</p>

<p>JSON comes from the beta Copilot reports namespace; CSV comes from the v1.0 endpoint. The connector routes each request to the right one based on the format you choose.</p>

<h2 id="operations">Operations</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Get Usage Report (<code class="language-plaintext highlighter-rouge">GetUsageReport</code>)</td>
      <td>Get per-user Copilot usage for a period, as JSON (default) or CSV.</td>
    </tr>
    <tr>
      <td>Invoke MCP (<code class="language-plaintext highlighter-rouge">InvokeMCP</code>)</td>
      <td>Model Context Protocol endpoint for Copilot Studio. Exposes the <code class="language-plaintext highlighter-rouge">get_usage_report</code> tool (JSON).</td>
    </tr>
  </tbody>
</table>

<h2 id="parameters">Parameters</h2>

<ul>
  <li><strong>Period</strong> — the number of previous days to aggregate. <code class="language-plaintext highlighter-rouge">v1</code> supports <code class="language-plaintext highlighter-rouge">D7, D30, D90, D180, ALL</code>; <code class="language-plaintext highlighter-rouge">v2</code> supports <code class="language-plaintext highlighter-rouge">D7, D28, D90, D180, ALL</code>.</li>
  <li><strong>Version</strong> — <code class="language-plaintext highlighter-rouge">v1</code> or <code class="language-plaintext highlighter-rouge">v2</code> (default). <strong>v2</strong> adds prompt counts (all apps, Copilot Chat work and web), active usage days, and last-activity dates for Microsoft 365 Copilot, Edge, and Copilot Agent.</li>
  <li><strong>Format</strong> — <code class="language-plaintext highlighter-rouge">json</code> (default) or <code class="language-plaintext highlighter-rouge">csv</code>.</li>
</ul>

<h2 id="example">Example</h2>

<p>Get the last 7 days as JSON (v2):</p>

<p>Request: <code class="language-plaintext highlighter-rouge">period = D7</code>, <code class="language-plaintext highlighter-rouge">version = v2</code>, <code class="language-plaintext highlighter-rouge">format = json</code></p>

<p>Response (abridged):</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">"value"</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">"reportRefreshDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-07-23"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"userPrincipalName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"avery@zava.com"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Avery Howard"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"lastActivityDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-07-22"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"copilotChatLastActivityDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-07-22"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"microsoftTeamsCopilotLastActivityDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-07-21"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"wordCopilotLastActivityDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-07-20"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"copilotActivityUserDetailsByPeriod"</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">"reportPeriod"</span><span class="p">:</span><span class="w"> </span><span class="mi">7</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>With <code class="language-plaintext highlighter-rouge">version=v2</code> (the default), the response carries extra data beyond the named outputs—prompt counts and active usage days inside each <code class="language-plaintext highlighter-rouge">copilotActivityUserDetailsByPeriod</code> entry, and additional last-activity dates at the top level. These pass through in the JSON; Graph doesn’t publish the exact property names, so they aren’t declared as named outputs.</p>

<h2 id="data-flow">Data flow</h2>

<pre><code class="language-mermaid">sequenceDiagram
    participant Client as Copilot Studio / Power Automate
    participant Conn as Connector
    participant Graph as Microsoft Graph
    participant Data as Usage Reporting

    Client-&gt;&gt;Conn: Get Usage Report { period, version, format }
    alt format = json (default)
        Conn-&gt;&gt;Graph: GET /beta/copilot/reports/getMicrosoft365CopilotUsageUserDetail(period, version)
        Graph--&gt;&gt;Conn: 200 JSON { value: [ per-user detail ] }
    else format = csv
        Conn-&gt;&gt;Graph: GET /v1.0/copilot/reports/getMicrosoft365CopilotUsageUserDetail(period, version)
        Graph--&gt;&gt;Conn: 200 CSV stream
    end
    Conn--&gt;&gt;Client: Report (JSON or CSV)

    Note over Client,Data: Delegated Reports.Read.All + a supported admin role
</code></pre>

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

<ul>
  <li>A Microsoft Entra ID <strong>app registration</strong>. This connector uses the generic <code class="language-plaintext highlighter-rouge">aad</code> identity provider with your own client ID and secret.</li>
  <li>The signing-in user must hold a supported <strong>admin role</strong>—for example <strong>Reports Reader</strong>, <strong>Usage Summary Reports Reader</strong>, <strong>AI Administrator</strong>, or <strong>Global Reader</strong>.</li>
</ul>

<h2 id="set-up-credentials">Set up credentials</h2>

<p>The connector uses OAuth 2.0 (authorization code) with Microsoft Entra ID and the <strong><code class="language-plaintext highlighter-rouge">Reports.Read.All</code></strong> delegated permission:</p>

<ol>
  <li>In the <a href="https://entra.microsoft.com">Microsoft Entra admin center</a>, register a new application.</li>
  <li>Add a <strong>Web</strong> redirect URI: <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code>.</li>
  <li>Under <strong>API permissions</strong>, add the delegated Microsoft Graph permission <strong><code class="language-plaintext highlighter-rouge">Reports.Read.All</code></strong> and grant admin consent.</li>
  <li>Under <strong>Certificates &amp; secrets</strong>, create a client secret. Record the Application (client) ID and secret value.</li>
  <li>Set the client ID in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> (<code class="language-plaintext highlighter-rouge">clientId</code>) and provide the client secret on the connector’s <strong>Security</strong> tab after deployment.</li>
</ol>

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

<p>A known PAC CLI issue blocks OAuth <code class="language-plaintext highlighter-rouge">connectionParameters</code> on create, so deploy in two steps and configure OAuth in the portal:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Create the connector with the definition, properties, and script</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.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="c"># 2. In the Power Platform portal, open the connector's Security tab and set:</span><span class="w">
</span><span class="c">#    - Client ID and Client secret from your app registration</span><span class="w">
</span><span class="c">#    - Confirm the redirect URL matches https://global.consent.azure-apim.net/redirect</span><span class="w">
</span></code></pre></div></div>

<h2 id="telemetry">Telemetry</h2>

<p><code class="language-plaintext highlighter-rouge">script.csx</code> includes an Application Insights hook (<code class="language-plaintext highlighter-rouge">LogToAppInsights</code>) that emits events for requests, Graph calls, MCP tool calls, and errors. It’s disabled by default—the instrumentation key is a placeholder, and telemetry is skipped until you set a real key. Replace the <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> constant to turn it on. Telemetry failures are swallowed and never block an operation.</p>

<h2 id="limitations">Limitations</h2>

<ul>
  <li><strong>JSON comes from the beta namespace</strong> — the v1.0 Copilot reports endpoint returns CSV, so this connector routes JSON requests to <code class="language-plaintext highlighter-rouge">/beta/copilot/reports/...</code>. APIs under <code class="language-plaintext highlighter-rouge">/beta</code> are subject to change.</li>
  <li><strong>Licensed users only</strong> — the report returns usage for users with a Microsoft 365 Copilot license. Unlicensed Copilot Chat usage isn’t available here (check the admin center Copilot Chat Usage report or Purview audit logs).</li>
  <li><strong>Admin role required</strong> — the signed-in user must hold a supported reports or admin role, not just the app permission.</li>
  <li><strong>Data anonymization</strong> — usage report data may be anonymized depending on the tenant’s admin center reports privacy setting.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/api/admin-settings/reports/copilotreportroot-getmicrosoft365copilotusageuserdetail">copilotReportRoot: getMicrosoft365CopilotUsageUserDetail</a></li>
  <li><a href="https://learn.microsoft.com/en-us/microsoft-365/admin/activity-reports/microsoft-365-copilot-usage">Microsoft 365 Copilot usage report (admin center)</a></li>
  <li><a href="https://learn.microsoft.com/en-us/graph/reportroot-authorization">Authorization for APIs to read Microsoft 365 usage reports</a></li>
</ul>

<p>Full source is in the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Usage%20Reports">SharingIsCaring repository</a>.</p>

<p>#PowerPlatform #CopilotStudio #MCP #CustomConnectors #Reporting #GraphAPI</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Copilot" /><category term="MCP" /><category term="Custom Connectors" /><category term="Power Platform" /><category term="Copilot Studio" /><category term="Microsoft Graph" /><category term="Reporting" /><summary type="html"><![CDATA[Power Platform custom MCP connector for the Microsoft Graph Copilot usage reports API. Pull per-user Copilot activity across Teams, Word, Excel, Outlook, and Copilot Chat as JSON or CSV for adoption dashboards.]]></summary></entry><entry><title type="html">Export Microsoft 365 Copilot interaction history to Power Platform</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-25-copilot-interaction-export-connector.html" rel="alternate" type="text/html" title="Export Microsoft 365 Copilot interaction history to Power Platform" /><published>2026-07-25T14:00:00+00:00</published><updated>2026-07-25T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/copilot-interaction-export-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-25-copilot-interaction-export-connector.html"><![CDATA[<p>Every Copilot prompt and response across Teams, Word, Outlook, and BizChat is captured in the interaction history service. Compliance teams need that record for auditing, and analytics teams want it for adoption reporting. The Microsoft Graph AI Interaction Export API exposes it, and this custom MCP connector brings it into Power Automate and Copilot Studio.</p>

<p>You can find the complete code in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Interaction%20Export">SharingIsCaring repository</a>.</p>

<h2 id="app-only-authentication">App-only authentication</h2>

<p>This connector is different from the <a href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-23-copilot-chat-connector.html">Copilot Chat</a> and <a href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-24-copilot-search-connector.html">Copilot Search</a> connectors, which sign in as the user. The Export API supports <strong>application (app-only) permissions only</strong>. The connection authenticates as the app itself through the client credentials flow—no user signs in. Plan your app registration and connection around that.</p>

<h2 id="what-it-does">What it does</h2>

<p>The connector wraps the Microsoft Graph v1.0 <code class="language-plaintext highlighter-rouge">getAllEnterpriseInteractions</code> API. Pass a user ID and it returns that user’s Copilot interactions—prompts, responses, the app each one came from, accessed resources, and timestamps—so you can feed the data into compliance workflows, audit logs, or analytics.</p>

<h2 id="operations">Operations</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Get User Interactions (<code class="language-plaintext highlighter-rouge">GetUserInteractions</code>)</td>
      <td>Get all Copilot interactions for a user, with optional <code class="language-plaintext highlighter-rouge">$top</code> and <code class="language-plaintext highlighter-rouge">$filter</code>.</td>
    </tr>
    <tr>
      <td>Invoke MCP (<code class="language-plaintext highlighter-rouge">InvokeMCP</code>)</td>
      <td>Model Context Protocol endpoint for Copilot Studio. Exposes <code class="language-plaintext highlighter-rouge">get_user_interactions</code> and <code class="language-plaintext highlighter-rouge">get_user_interactions_by_app</code> tools.</td>
    </tr>
  </tbody>
</table>

<h2 id="parameters">Parameters</h2>

<ul>
  <li><strong>User ID</strong> — the object ID or user principal name of the user whose interactions to export.</li>
  <li><strong>Top</strong> — the number of interactions to return. <code class="language-plaintext highlighter-rouge">100</code> is a good value for performance.</li>
  <li><strong>Filter</strong> — an OData filter, for example <code class="language-plaintext highlighter-rouge">appClass eq 'IPM.SkypeTeams.Message.Copilot.BizChat'</code>.</li>
</ul>

<h2 id="mcp-tools-for-copilot-studio">MCP tools for Copilot Studio</h2>

<p>Invoke MCP is the Model Context Protocol endpoint. A Copilot Studio agent pointed at it gets two tools:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">get_user_interactions</code> — all interactions for a user</li>
  <li><code class="language-plaintext highlighter-rouge">get_user_interactions_by_app</code> — interactions filtered to a specific app class</li>
</ul>

<p>The by-app tool saves the agent from writing the OData filter by hand when someone asks for, say, just their Teams Copilot activity.</p>

<h2 id="example">Example</h2>

<p>Get a user’s BizChat interactions:</p>

<p>Request: <code class="language-plaintext highlighter-rouge">userId = 4db02e4b-d144-400e-b194-53253a34c5be</code>, <code class="language-plaintext highlighter-rouge">$filter = appClass eq 'IPM.SkypeTeams.Message.Copilot.BizChat'</code></p>

<p>Response (abridged):</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">"value"</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">"1732148357313"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"sessionId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"19:YzBP1kUdkNjFtJnketPYT8kQdQ3A08Y51rDTxE_ENIk1@thread.v2"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"appClass"</span><span class="p">:</span><span class="w"> </span><span class="s2">"IPM.SkypeTeams.Message.Copilot.BizChat"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"interactionType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aiResponse"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"conversationType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bizchat"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdDateTime"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2024-11-21T00:19:17.313Z"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"locale"</span><span class="p">:</span><span class="w"> </span><span class="s2">"en-us"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"contentType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"html"</span><span class="p">,</span><span class="w"> </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;attachment id=</span><span class="se">\"</span><span class="s2">4062...</span><span class="se">\"</span><span class="s2">&gt;&lt;/attachment&gt;"</span><span class="w"> </span><span class="p">},</span><span class="w">
      </span><span class="nl">"from"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"application"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft 365 Chat"</span><span class="p">,</span><span class="w"> </span><span class="nl">"applicationIdentityType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bot"</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>

<h2 id="data-flow">Data flow</h2>

<pre><code class="language-mermaid">sequenceDiagram
    participant Client as Copilot Studio / Power Automate
    participant Conn as Connector
    participant AAD as Microsoft Entra ID
    participant Graph as Microsoft Graph (v1.0)
    participant Hist as Interaction History

    Note over Conn,AAD: App-only (client credentials) — no user sign-in
    Conn-&gt;&gt;AAD: Token request (client_credentials, resource=Graph)
    AAD--&gt;&gt;Conn: App access token
    Client-&gt;&gt;Conn: Get User Interactions { userId, top, filter }
    Conn-&gt;&gt;Graph: GET /v1.0/copilot/users/{userId}/interactionHistory/getAllEnterpriseInteractions?$top&amp;$filter
    Graph-&gt;&gt;Hist: Read interaction history
    Hist--&gt;&gt;Graph: aiInteraction[]
    Graph--&gt;&gt;Conn: 200 { value: [ { id, appClass, body, from, ... } ] }
    Conn--&gt;&gt;Client: Interaction collection

    Note over Client,Hist: Requires AiEnterpriseInteraction.Read.All + Copilot license
</code></pre>

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

<ul>
  <li>A Microsoft Entra ID <strong>app registration</strong> with the <strong><code class="language-plaintext highlighter-rouge">AiEnterpriseInteraction.Read.All</code></strong> application permission and <strong>admin consent</strong> granted.</li>
  <li>Users whose interactions you export must have a valid <strong>Microsoft 365 Copilot</strong> license with the <em>Microsoft Copilot with Graph-grounded chat</em> service plan.</li>
</ul>

<h2 id="set-up-credentials">Set up credentials</h2>

<p>The connector authenticates as the application through the client credentials flow:</p>

<ol>
  <li>In the <a href="https://entra.microsoft.com">Microsoft Entra admin center</a>, register a new application.</li>
  <li>Under <strong>API permissions</strong>, add the <strong>Application</strong> permission <strong>Microsoft Graph → <code class="language-plaintext highlighter-rouge">AiEnterpriseInteraction.Read.All</code></strong>, then grant admin consent.</li>
  <li>Under <strong>Certificates &amp; secrets</strong>, create a client secret. Record the Application (client) ID, secret value, and your Directory (tenant) ID.</li>
  <li>Set the client ID in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> (<code class="language-plaintext highlighter-rouge">clientId</code>) and your tenant ID in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> (<code class="language-plaintext highlighter-rouge">customParameters.tenantId</code>). A concrete tenant is required—the client credentials flow can’t use <code class="language-plaintext highlighter-rouge">common</code>.</li>
  <li>On the connector’s <strong>Security</strong> tab after deployment, provide the client secret. The connection acquires an app-only token, so no user signs in.</li>
</ol>

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

<p>A known PAC CLI issue blocks OAuth <code class="language-plaintext highlighter-rouge">connectionParameters</code> on create, so deploy in two steps and configure OAuth in the portal:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Create the connector with the definition, properties, and script</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.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="c"># 2. In the Power Platform portal, open the connector's Security tab and set:</span><span class="w">
</span><span class="c">#    - Client ID and Client secret from your app registration</span><span class="w">
</span><span class="c">#    (Grant type = client_credentials and the tenant ID are already set in apiProperties.json)</span><span class="w">
</span></code></pre></div></div>

<h2 id="telemetry">Telemetry</h2>

<p><code class="language-plaintext highlighter-rouge">script.csx</code> includes an Application Insights hook (<code class="language-plaintext highlighter-rouge">LogToAppInsights</code>) that emits events for requests, Graph calls, MCP tool calls, and errors. It’s disabled by default—the instrumentation key is a placeholder, and telemetry is skipped until you set a real key. Replace the <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> constant to turn it on. Telemetry failures are swallowed and never block an operation.</p>

<h2 id="limitations">Limitations</h2>

<ul>
  <li><strong>Application permission only</strong> — delegated (user) sign-in isn’t supported by this API.</li>
  <li><strong>Copilot license required</strong> — only interactions for users with a Microsoft 365 Copilot license are returned.</li>
  <li><strong>No delta</strong> — the delta function isn’t supported; use <code class="language-plaintext highlighter-rouge">$top</code> paging (<code class="language-plaintext highlighter-rouge">@odata.nextLink</code>) to retrieve large histories.</li>
  <li><strong>Copilot Studio agents excluded</strong> — interactions inside agents created by Copilot Studio aren’t returned.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/api/ai-services/interaction-export/overview">AI Interaction Export API overview</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/api/ai-services/interaction-export/aiinteractionhistory-getallenterpriseinteractions">aiInteractionHistory: getAllEnterpriseInteractions</a></li>
  <li><a href="https://learn.microsoft.com/en-us/microsoftteams/export-teams-content">Export content with the Microsoft Teams export APIs</a></li>
</ul>

<p>Full source is in the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Interaction%20Export">SharingIsCaring repository</a>.</p>

<p>#PowerPlatform #CopilotStudio #MCP #CustomConnectors #Compliance #GraphAPI</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Copilot" /><category term="MCP" /><category term="Custom Connectors" /><category term="Power Platform" /><category term="Copilot Studio" /><category term="Microsoft Graph" /><category term="Compliance" /><summary type="html"><![CDATA[Power Platform custom MCP connector for the Microsoft Graph AI Interaction Export API. Pull Copilot prompts and responses across Teams, Word, Outlook, and BizChat for compliance, auditing, and analytics using app-only authentication.]]></summary></entry><entry><title type="html">GraphQL Bridge: a REST-to-GraphQL template for custom connectors</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-24-graphql-bridge-power-platform-connector.html" rel="alternate" type="text/html" title="GraphQL Bridge: a REST-to-GraphQL template for custom connectors" /><published>2026-07-24T15:00:00+00:00</published><updated>2026-07-24T15:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/graphql-bridge-power-platform-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-24-graphql-bridge-power-platform-connector.html"><![CDATA[<p>GraphQL and Power Platform’s Swagger model don’t agree on much. GraphQL sends one <code class="language-plaintext highlighter-rouge">POST</code> with a <code class="language-plaintext highlighter-rouge">{ query, variables }</code> envelope to a single endpoint. Power Platform models operations per path and verb, expects clean request and response schemas, and treats HTTP 200 as success. Point a custom connector straight at a GraphQL API and you get raw query strings in your flows, false successes on failed queries, and response data buried under a <code class="language-plaintext highlighter-rouge">data</code> root.</p>

<p>GraphQL Bridge is a reusable <code class="language-plaintext highlighter-rouge">script.csx</code> template that reconciles all three. Callers send flat JSON, the script builds the GraphQL envelope, posts it, and returns clean JSON. No GraphQL knowledge is needed in the connector operations, Power Automate, or Copilot Studio.</p>

<p>You can find the complete code in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/GraphQL%20Bridge">SharingIsCaring repository</a>.</p>

<h2 id="why-this-is-needed">Why this is needed</h2>

<p>Three mismatches between GraphQL and Power Platform’s Swagger model drive the whole script:</p>

<table>
  <thead>
    <tr>
      <th>GraphQL behavior</th>
      <th>Power Platform problem</th>
      <th>What the bridge does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Single endpoint, <code class="language-plaintext highlighter-rouge">POST { query, variables }</code></td>
      <td>Operations are modeled per path and verb</td>
      <td>Builds the envelope from a flat body and retargets the request at the GraphQL path</td>
    </tr>
    <tr>
      <td>Returns HTTP 200 even on failure (<code class="language-plaintext highlighter-rouge">errors</code> array)</td>
      <td>Power Automate sees a false success</td>
      <td>Inspects <code class="language-plaintext highlighter-rouge">errors</code> and returns a real HTTP 400 with the messages</td>
    </tr>
    <tr>
      <td>Nests results under <code class="language-plaintext highlighter-rouge">data</code> (and deeper)</td>
      <td>Response schemas map poorly to <code class="language-plaintext highlighter-rouge">data.brand.assets</code></td>
      <td>Unwraps <code class="language-plaintext highlighter-rouge">data</code> and an optional per-operation sub-path</td>
    </tr>
  </tbody>
</table>

<h2 id="how-it-works">How it works</h2>

<p>The bridge chooses one of two modes per <code class="language-plaintext highlighter-rouge">OperationId</code>.</p>

<h3 id="mode-a--server-defined-document-recommended">Mode A — server-defined document (recommended)</h3>

<p>Register the GraphQL document in <code class="language-plaintext highlighter-rouge">GraphQlDocuments</code>, keyed by <code class="language-plaintext highlighter-rouge">OperationId</code>. The connector body passes straight through as GraphQL variables, so the operation looks like a normal typed REST call:</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">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">&gt;</span> <span class="n">GraphQlDocuments</span> <span class="p">=</span>
    <span class="k">new</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</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="p">[</span><span class="s">"GetAssetById"</span><span class="p">]</span> <span class="p">=</span> <span class="s">@"query GetAssetById($id: ID!) {
        asset(id: $id) { id title status createdAt }
    }"</span><span class="p">,</span>
<span class="p">};</span>

<span class="c1">// Optional: return exactly the nested object the Swagger schema expects</span>
<span class="k">private</span> <span class="k">static</span> <span class="k">readonly</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">&gt;</span> <span class="n">ResponseRootPaths</span> <span class="p">=</span>
    <span class="k">new</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</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="p">[</span><span class="s">"GetAssetById"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"asset"</span><span class="p">,</span>   <span class="c1">// returns data.asset instead of the whole data object</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The caller sends a flat body:</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">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"abc123"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The script wraps it as variables, posts the registered document, and returns <code class="language-plaintext highlighter-rouge">data.asset</code>. The maker never sees GraphQL.</p>

<h3 id="mode-b--passthrough">Mode B — passthrough</h3>

<p>If no document is registered for the <code class="language-plaintext highlighter-rouge">OperationId</code>, the body must carry the query itself. Wire this to a generic <code class="language-plaintext highlighter-rouge">RunQuery</code> operation for ad-hoc queries:</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">"query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"query ($id: ID!) { asset(id: $id) { id title } }"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"variables"</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">"abc123"</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>Reserved body keys (<code class="language-plaintext highlighter-rouge">query</code>, <code class="language-plaintext highlighter-rouge">variables</code>, <code class="language-plaintext highlighter-rouge">operationName</code>) are always honored. In Mode A, everything else in the body becomes variables.</p>

<h2 id="configuration">Configuration</h2>

<p>Four settings control the bridge, all at the top of <code class="language-plaintext highlighter-rouge">script.csx</code>:</p>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GraphQlPath</code></td>
      <td>Path appended to the connector host (for example, <code class="language-plaintext highlighter-rouge">/graphql</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GraphQlDocuments</code></td>
      <td><code class="language-plaintext highlighter-rouge">OperationId</code> to GraphQL document (Mode A)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ResponseRootPaths</code></td>
      <td><code class="language-plaintext highlighter-rouge">OperationId</code> to dot-path into <code class="language-plaintext highlighter-rouge">data</code> to unwrap (for example, <code class="language-plaintext highlighter-rouge">customer.orders</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PassthroughOperationId</code></td>
      <td><code class="language-plaintext highlighter-rouge">OperationId</code> reserved for the generic query runner (informational)</td>
    </tr>
  </tbody>
</table>

<p>Auth is untouched. The incoming request already carries the connector’s <code class="language-plaintext highlighter-rouge">Authorization</code> header. The script only changes the method, URI path, and content, so credentials flow through automatically.</p>

<h2 id="error-shapes-returned">Error shapes returned</h2>

<p>The bridge turns silent GraphQL failures into real HTTP errors your flows can catch:</p>

<ul>
  <li>Invalid request JSON returns <code class="language-plaintext highlighter-rouge">400 INVALID_JSON</code></li>
  <li>No query available returns <code class="language-plaintext highlighter-rouge">400 NO_QUERY</code></li>
  <li>A GraphQL <code class="language-plaintext highlighter-rouge">errors</code> array returns <code class="language-plaintext highlighter-rouge">400 GRAPHQL_ERROR</code>, with <code class="language-plaintext highlighter-rouge">errors</code> and any <code class="language-plaintext highlighter-rouge">partialData</code></li>
  <li>A non-2xx from the GraphQL server returns the original status, wrapped as <code class="language-plaintext highlighter-rouge">HTTP_&lt;code&gt;</code></li>
  <li>A non-JSON GraphQL response returns <code class="language-plaintext highlighter-rouge">502 INVALID_GRAPHQL_RESPONSE</code></li>
</ul>

<h2 id="using-it-for-a-real-api">Using it for a real API</h2>

<p>Most GraphQL APIs expose a single endpoint and use a bearer token:</p>

<ul>
  <li><strong>Host</strong>: the API’s host, such as <code class="language-plaintext highlighter-rouge">api.example.com</code></li>
  <li><strong>Path</strong>: <code class="language-plaintext highlighter-rouge">/graphql</code> (the default <code class="language-plaintext highlighter-rouge">GraphQlPath</code>; change it if the API differs)</li>
  <li><strong>Auth</strong>: OAuth 2.0 or an API or personal access token, configured as the connector’s security definition</li>
</ul>

<p>Steps to adapt it:</p>

<ol>
  <li>Point the connector host at the API and set <code class="language-plaintext highlighter-rouge">GraphQlPath</code> to its GraphQL endpoint path.</li>
  <li>Add one entry to <code class="language-plaintext highlighter-rouge">GraphQlDocuments</code> per typed operation you want to expose, for queries and mutations.</li>
  <li>Optionally set <code class="language-plaintext highlighter-rouge">ResponseRootPaths</code> so each operation returns the exact nested object.</li>
  <li>Add a generic <code class="language-plaintext highlighter-rouge">RunQuery</code> operation for anything not yet modeled (Mode B).</li>
</ol>

<p>Because the bridge exposes schema-shaped typed operations rather than a raw passthrough, pair each build with full Swagger response schemas so you keep IntelliSense and dynamic value support in Power Automate.</p>

<p>For per-tenant hosts, where the GraphQL host varies per customer (for example, <code class="language-plaintext highlighter-rouge">{tenant}.example.com</code>), Power Platform’s Swagger host is static. Bake the host as a replaceable constant in the script and set the request URI there instead of relying on <code class="language-plaintext highlighter-rouge">GraphQlPath</code> alone.</p>

<h2 id="conventions">Conventions</h2>

<ul>
  <li>Uses <code class="language-plaintext highlighter-rouge">this.Context.SendAsync(...)</code>, never <code class="language-plaintext highlighter-rouge">new HttpClient()</code>, which the runtime blocks</li>
  <li>Fully qualifies <code class="language-plaintext highlighter-rouge">Newtonsoft.Json.Formatting</code> to avoid the <code class="language-plaintext highlighter-rouge">System.Xml</code> ambiguity</li>
  <li>Requires no connection parameters; the endpoint path is a compile-time constant</li>
</ul>

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

<ul>
  <li>Template repo: <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/GraphQL%20Bridge">SharingIsCaring/Connector-Code/GraphQL Bridge</a></li>
  <li>GraphQL specification: <a href="https://spec.graphql.org/">spec.graphql.org</a></li>
  <li>Custom connector C# code reference: <a href="https://learn.microsoft.com/en-us/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="GraphQL" /><category term="Custom Connectors" /><category term="Power Platform" /><category term="Power Automate" /><category term="Copilot Studio" /><category term="script.csx" /><category term="Integration" /><summary type="html"><![CDATA[A reusable script.csx template that turns REST-style Power Platform custom connector operations into a JSON to GraphQL to JSON bridge, so callers never touch GraphQL syntax.]]></summary></entry><entry><title type="html">Copilot Search: enterprise search for agents and flows</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-24-copilot-search-connector.html" rel="alternate" type="text/html" title="Copilot Search: enterprise search for agents and flows" /><published>2026-07-24T14:00:00+00:00</published><updated>2026-07-24T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/copilot-search-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-24-copilot-search-connector.html"><![CDATA[<p>Search sits under everything a Microsoft 365 agent does. Before Copilot can summarize a document or answer a question, it has to find the right content across files, email, Teams messages, calendar events, SharePoint, and connector items. The Microsoft Graph Search API is the same index that powers that retrieval. This custom MCP connector puts it behind a Power Platform connector, so a Power Automate flow or a Copilot Studio agent can run the same enterprise search—with every result scoped to the signed-in user’s access.</p>

<p>You can find the complete code in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Search">SharingIsCaring repository</a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>The MCP connector wraps the Microsoft Graph v1.0 Search API (<code class="language-plaintext highlighter-rouge">POST /v1.0/search/query</code>). Send a query and the entity types you want, and Graph returns ranked hits with summaries and resource links. Permissions are evaluated per item, so users only see content they already have access to.</p>

<p>Searchable content includes:</p>

<ul>
  <li>Files in OneDrive and SharePoint</li>
  <li>Email</li>
  <li>Teams messages</li>
  <li>Calendar events</li>
  <li>SharePoint sites and lists</li>
  <li>People</li>
  <li>Copilot connector (external) items</li>
</ul>

<h2 id="operations">Operations</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Search (<code class="language-plaintext highlighter-rouge">Search</code>)</td>
      <td>Search one or more entity types with a query, paging, and optional field selection.</td>
    </tr>
    <tr>
      <td>Invoke MCP (<code class="language-plaintext highlighter-rouge">InvokeMCP</code>)</td>
      <td>Model Context Protocol endpoint for Copilot Studio. Exposes <code class="language-plaintext highlighter-rouge">search</code>, <code class="language-plaintext highlighter-rouge">search_files</code>, <code class="language-plaintext highlighter-rouge">search_email</code>, <code class="language-plaintext highlighter-rouge">search_teams_messages</code>, <code class="language-plaintext highlighter-rouge">search_events</code>, and <code class="language-plaintext highlighter-rouge">search_external_items</code> tools.</td>
    </tr>
  </tbody>
</table>

<h2 id="search-parameters">Search parameters</h2>

<ul>
  <li><strong>Query</strong> — the search text. Supports <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference">Keyword Query Language (KQL)</a>, for example <code class="language-plaintext highlighter-rouge">budget filetype:xlsx</code>.</li>
  <li><strong>Entity Types</strong> — content types to search. File types (<code class="language-plaintext highlighter-rouge">site</code>, <code class="language-plaintext highlighter-rouge">drive</code>, <code class="language-plaintext highlighter-rouge">driveItem</code>, <code class="language-plaintext highlighter-rouge">list</code>, <code class="language-plaintext highlighter-rouge">listItem</code>) must be searched together and can’t be combined with non-file types in one request.</li>
  <li><strong>From / Size</strong> — paging, using a zero-based offset and page size.</li>
  <li><strong>Fields</strong> — specific properties to return per hit.</li>
  <li><strong>Query Template</strong> — advanced KQL template, for example <code class="language-plaintext highlighter-rouge">{searchTerms} CreatedBy:Bob</code>.</li>
  <li><strong>Content Sources</strong> — for <code class="language-plaintext highlighter-rouge">externalItem</code>, the connector connection(s) to query, for example <code class="language-plaintext highlighter-rouge">/external/connections/connectionId</code>.</li>
  <li><strong>Enable Top Results</strong> — for <code class="language-plaintext highlighter-rouge">message</code>, return the most relevant results first.</li>
</ul>

<h2 id="mcp-tools-for-copilot-studio">MCP tools for Copilot Studio</h2>

<p>Invoke MCP is the Model Context Protocol endpoint. Point a Copilot Studio agent at it and the agent gets six tools:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">search</code> — general search across entity types</li>
  <li><code class="language-plaintext highlighter-rouge">search_files</code> — OneDrive and SharePoint files</li>
  <li><code class="language-plaintext highlighter-rouge">search_email</code> — mail</li>
  <li><code class="language-plaintext highlighter-rouge">search_teams_messages</code> — Teams chat and channel messages</li>
  <li><code class="language-plaintext highlighter-rouge">search_events</code> — calendar events</li>
  <li><code class="language-plaintext highlighter-rouge">search_external_items</code> — Copilot connector content</li>
</ul>

<p>The dedicated tools save the agent from choosing entity types by hand—ask for files and the agent calls <code class="language-plaintext highlighter-rouge">search_files</code> directly.</p>

<h2 id="example">Example</h2>

<p>Search files:</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">"queryString"</span><span class="p">:</span><span class="w"> </span><span class="s2">"quarterly budget filetype:xlsx"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"entityTypes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="s2">"driveItem"</span><span class="w"> </span><span class="p">],</span><span class="w">
  </span><span class="nl">"size"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Response (abridged):</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">"value"</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">"searchTerms"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="s2">"quarterly"</span><span class="p">,</span><span class="w"> </span><span class="s2">"budget"</span><span class="w"> </span><span class="p">],</span><span class="w">
      </span><span class="nl">"hitsContainers"</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">"total"</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">"moreResultsAvailable"</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">"hits"</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">"hitId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"01ABC..."</span><span class="p">,</span><span class="w">
              </span><span class="nl">"rank"</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">"summary"</span><span class="p">:</span><span class="w"> </span><span class="s2">"...quarterly &lt;c0&gt;budget&lt;/c0&gt; figures..."</span><span class="p">,</span><span class="w">
              </span><span class="nl">"resource"</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">"Q3-Budget.xlsx"</span><span class="p">,</span><span class="w"> </span><span class="nl">"webUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://..."</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><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="data-flow">Data flow</h2>

<pre><code class="language-mermaid">sequenceDiagram
    participant Client as Copilot Studio / Power Automate
    participant Conn as Connector
    participant Graph as Microsoft Graph (v1.0)
    participant M365 as Microsoft 365 Content

    Client-&gt;&gt;Conn: Search { queryString, entityTypes, from, size, fields }
    Conn-&gt;&gt;Graph: POST /v1.0/search/query { requests: [ { entityTypes, query, from, size } ] }
    Graph-&gt;&gt;M365: Query indexed content (permissions evaluated per item)
    M365--&gt;&gt;Graph: Authorized hits
    Graph--&gt;&gt;Conn: 200 { value: [ { hitsContainers: [ { hits[], total } ] } ] }
    Conn--&gt;&gt;Client: Ranked hits with summaries + resources

    Note over Client,M365: OAuth 2.0 delegated — results scoped to the signed-in user
</code></pre>

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

<ul>
  <li>A Microsoft Entra ID <strong>app registration</strong>. This connector uses the generic <code class="language-plaintext highlighter-rouge">aad</code> identity provider with your own client ID and secret.</li>
  <li><strong>Delegated permissions</strong> — searches run in the context of the signed-in user.</li>
</ul>

<h2 id="set-up-credentials">Set up credentials</h2>

<p>The connector uses OAuth 2.0 (authorization code) with Microsoft Entra ID. Register an app and grant the delegated Microsoft Graph permissions for the entity types you plan to search:</p>

<table>
  <thead>
    <tr>
      <th>Entity type</th>
      <th>Delegated permission</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">message</code> (email)</td>
      <td><code class="language-plaintext highlighter-rouge">Mail.Read</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">event</code> (calendar)</td>
      <td><code class="language-plaintext highlighter-rouge">Calendars.Read</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">chatMessage</code> (Teams)</td>
      <td><code class="language-plaintext highlighter-rouge">Chat.Read</code>, <code class="language-plaintext highlighter-rouge">ChannelMessage.Read.All</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">driveItem</code>, <code class="language-plaintext highlighter-rouge">drive</code> (files)</td>
      <td><code class="language-plaintext highlighter-rouge">Files.Read.All</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">site</code>, <code class="language-plaintext highlighter-rouge">list</code>, <code class="language-plaintext highlighter-rouge">listItem</code> (SharePoint)</td>
      <td><code class="language-plaintext highlighter-rouge">Sites.Read.All</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">externalItem</code> (connectors)</td>
      <td><code class="language-plaintext highlighter-rouge">ExternalItem.Read.All</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">person</code> (people)</td>
      <td><code class="language-plaintext highlighter-rouge">People.Read</code></td>
    </tr>
  </tbody>
</table>

<p>Answer types <code class="language-plaintext highlighter-rouge">bookmark</code>, <code class="language-plaintext highlighter-rouge">acronym</code>, and <code class="language-plaintext highlighter-rouge">qna</code> are also supported by the Search API but require <code class="language-plaintext highlighter-rouge">Bookmark.Read.All</code>, <code class="language-plaintext highlighter-rouge">Acronym.Read.All</code>, and <code class="language-plaintext highlighter-rouge">QnA.Read.All</code> respectively. Add those scopes if you need them.</p>

<p>Steps:</p>

<ol>
  <li>In the <a href="https://entra.microsoft.com">Microsoft Entra admin center</a>, register a new application.</li>
  <li>Add a <strong>Web</strong> redirect URI: <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code>.</li>
  <li>Under <strong>API permissions</strong>, add the delegated permissions above and grant admin consent.</li>
  <li>Under <strong>Certificates &amp; secrets</strong>, create a client secret. Record the Application (client) ID and secret value.</li>
  <li>Set the client ID in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> (<code class="language-plaintext highlighter-rouge">clientId</code>) and provide the client secret on the connector’s <strong>Security</strong> tab after deployment.</li>
</ol>

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

<p>A known PAC CLI issue blocks OAuth <code class="language-plaintext highlighter-rouge">connectionParameters</code> on create, so deploy in two steps and configure OAuth in the portal:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Create the connector with the definition, properties, and script</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.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="c"># 2. In the Power Platform portal, open the connector's Security tab and set:</span><span class="w">
</span><span class="c">#    - Client ID and Client secret from your app registration</span><span class="w">
</span><span class="c">#    - Confirm the redirect URL matches https://global.consent.azure-apim.net/redirect</span><span class="w">
</span></code></pre></div></div>

<h2 id="telemetry">Telemetry</h2>

<p><code class="language-plaintext highlighter-rouge">script.csx</code> includes an Application Insights hook (<code class="language-plaintext highlighter-rouge">LogToAppInsights</code>) that emits events for requests, Graph calls, MCP tool calls, and errors. It’s disabled by default—the instrumentation key is a placeholder, and telemetry is skipped until you set a real key. Replace the <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> constant to turn it on. Telemetry failures are swallowed and never block an operation.</p>

<h2 id="limitations">Limitations</h2>

<ul>
  <li><strong>Delegated only</strong> — this connector runs user-context search. Application-permission search for <code class="language-plaintext highlighter-rouge">driveItem</code>/<code class="language-plaintext highlighter-rouge">listItem</code> also requires a <code class="language-plaintext highlighter-rouge">region</code> in the request and isn’t exposed here.</li>
  <li><strong>File-type interleaving</strong> — file entity types must all be in the same request and can’t mix with non-file types.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">person</code> can’t be combined</strong> with any other entity type in a single request—search people on their own.</li>
  <li><strong>Paging</strong> — <code class="language-plaintext highlighter-rouge">from</code>/<code class="language-plaintext highlighter-rouge">size</code> must be consistent when combining entity types. Page size maxes at 25 for <code class="language-plaintext highlighter-rouge">message</code> and <code class="language-plaintext highlighter-rouge">event</code>, and up to 1000 for SharePoint and OneDrive types.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/graph/search-concept-overview">Microsoft Search API overview</a></li>
  <li><a href="https://learn.microsoft.com/graph/api/search-query?view=graph-rest-1.0">searchEntity: query</a></li>
  <li><a href="https://learn.microsoft.com/graph/api/resources/search-api-overview?view=graph-rest-1.0">Scope search based on entity types</a></li>
</ul>

<p>Full source is in the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Search">SharingIsCaring repository</a>.</p>

<p>#PowerPlatform #CopilotStudio #MCP #CustomConnectors #MicrosoftSearch #GraphAPI</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Copilot" /><category term="MCP" /><category term="Custom Connectors" /><category term="Power Platform" /><category term="Copilot Studio" /><category term="Microsoft Graph" /><category term="Microsoft Search" /><summary type="html"><![CDATA[Power Platform custom MCP connector for the Microsoft Graph v1.0 Search API. Search files, email, Teams messages, events, SharePoint, people, and connector items across Microsoft 365 with results scoped to the signed-in user.]]></summary></entry><entry><title type="html">Microsoft 365 Copilot Chat connector for Power Platform</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-23-copilot-chat-connector.html" rel="alternate" type="text/html" title="Microsoft 365 Copilot Chat connector for Power Platform" /><published>2026-07-23T14:00:00+00:00</published><updated>2026-07-23T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/copilot-chat-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-07-23-copilot-chat-connector.html"><![CDATA[<p>Microsoft 365 Copilot answers questions with your enterprise data behind it—your mail, calendar, chats, files, and people. The Graph beta Copilot Chat API lets you send prompts to that same engine from your own apps. This connector wraps that API for Power Platform, so you can ask Copilot from a Power Automate flow or hand three MCP tools to a Copilot Studio agent.</p>

<p>You can find the complete code in my <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Chat">SharingIsCaring repository</a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>The connector calls the Microsoft Graph beta Copilot Chat API (<code class="language-plaintext highlighter-rouge">/beta/copilot/conversations</code>). You send a prompt, Copilot grounds its answer on the signed-in user’s Microsoft 365 data—respecting that user’s access controls—and returns a reply with citations. Because it runs as the signed-in user, every question needs a Microsoft 365 Copilot license.</p>

<h2 id="operations">Operations</h2>

<p>Four operations cover single questions, multi-turn conversations, and Copilot Studio.</p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ask Copilot (<code class="language-plaintext highlighter-rouge">AskCopilot</code>)</td>
      <td>One-shot: creates a conversation and sends the first prompt, returning Copilot’s reply. Best for single questions.</td>
    </tr>
    <tr>
      <td>Create Conversation (<code class="language-plaintext highlighter-rouge">CreateConversation</code>)</td>
      <td>Creates a conversation and returns its ID for multi-turn chat.</td>
    </tr>
    <tr>
      <td>Send Chat Message (<code class="language-plaintext highlighter-rouge">SendChatMessage</code>)</td>
      <td>Sends a prompt to an existing conversation and returns the full conversation with Copilot’s response.</td>
    </tr>
    <tr>
      <td>Invoke MCP (<code class="language-plaintext highlighter-rouge">InvokeMCP</code>)</td>
      <td>Model Context Protocol endpoint for Copilot Studio. Exposes <code class="language-plaintext highlighter-rouge">ask_copilot</code>, <code class="language-plaintext highlighter-rouge">create_conversation</code>, and <code class="language-plaintext highlighter-rouge">send_message</code> tools.</td>
    </tr>
  </tbody>
</table>

<p>Use Ask Copilot when you have one question. For a back-and-forth, call Create Conversation once, then call Send Chat Message with the returned <code class="language-plaintext highlighter-rouge">conversationId</code> for each follow-up.</p>

<h2 id="grounding-options">Grounding options</h2>

<p>Ask Copilot and Send Chat Message accept extra context to steer the answer:</p>

<ul>
  <li><strong>Time Zone</strong> — IANA time zone (for example, <code class="language-plaintext highlighter-rouge">America/New_York</code>) used to interpret time-relative prompts like “tomorrow morning.” Defaults to <code class="language-plaintext highlighter-rouge">UTC</code>.</li>
  <li><strong>Additional Context</strong> — a list of free-text strings added as grounding.</li>
  <li><strong>File URLs</strong> — OneDrive or SharePoint file URLs to use as context.</li>
  <li><strong>Enable Web Search</strong> — set to <code class="language-plaintext highlighter-rouge">false</code> to restrict grounding to enterprise data only.</li>
</ul>

<h2 id="example">Example</h2>

<p>Ask Copilot:</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">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"What meetings do I have tomorrow morning?"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"timeZone"</span><span class="p">:</span><span class="w"> </span><span class="s2">"America/New_York"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Response:</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">"0d110e7e-2b7e-4270-a899-fd2af6fde333"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"reply"</span><span class="p">:</span><span class="w"> </span><span class="s2">"You have 1 meeting tomorrow at 9 AM: Zava Engineering Standup..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"turnCount"</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">"conversation"</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">"0d110e7e-..."</span><span class="p">,</span><span class="w"> </span><span class="nl">"messages"</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>For a multi-turn thread, call Create Conversation, then Send Chat Message repeatedly with the returned <code class="language-plaintext highlighter-rouge">conversationId</code>.</p>

<h2 id="mcp-tools-for-copilot-studio">MCP tools for Copilot Studio</h2>

<p>Invoke MCP is the Model Context Protocol endpoint. Point a Copilot Studio agent at it and the agent gets three tools:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ask_copilot</code> — send a prompt and get a grounded reply</li>
  <li><code class="language-plaintext highlighter-rouge">create_conversation</code> — start a multi-turn conversation</li>
  <li><code class="language-plaintext highlighter-rouge">send_message</code> — continue a conversation by ID</li>
</ul>

<p>The agent extracts parameters from the user’s request, calls the right tool, and reads back Copilot’s answer. That gives you Copilot’s enterprise grounding inside a Copilot Studio agent alongside your other tools.</p>

<h2 id="data-flow">Data flow</h2>

<pre><code class="language-mermaid">sequenceDiagram
    participant Client as Copilot Studio / Power Automate
    participant Conn as Connector
    participant Graph as Microsoft Graph (beta)
    participant M365 as Microsoft 365 Data

    Note over Client,M365: Ask Copilot (one-shot)
    Client-&gt;&gt;Conn: Ask Copilot { text, timeZone }
    Conn-&gt;&gt;Graph: POST /beta/copilot/conversations {}
    Graph--&gt;&gt;Conn: 201 { id }
    Conn-&gt;&gt;Graph: POST /beta/copilot/conversations/:id/chat
    Graph-&gt;&gt;M365: Ground on Mail, Calendar, Chats, Files, People
    M365--&gt;&gt;Graph: Authorized content + citations
    Graph--&gt;&gt;Conn: 200 copilotConversation { messages[] }
    Conn--&gt;&gt;Client: { conversationId, reply, turnCount }
</code></pre>

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

<ul>
  <li>A <strong>Microsoft 365 Copilot</strong> license for every user who signs in to the connection.</li>
  <li>A Microsoft Entra ID <strong>app registration</strong>. This connector uses the generic <code class="language-plaintext highlighter-rouge">aad</code> identity provider with your own client ID and secret.</li>
  <li><strong>Delegated permissions only</strong> — the Chat API doesn’t support application permissions.</li>
</ul>

<h2 id="set-up-credentials">Set up credentials</h2>

<p>The connector uses OAuth 2.0 (authorization code) with Microsoft Entra ID. Register an app and grant these delegated Microsoft Graph permissions—all are required for the Chat API to succeed:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Sites.Read.All</code></li>
  <li><code class="language-plaintext highlighter-rouge">Mail.Read</code></li>
  <li><code class="language-plaintext highlighter-rouge">People.Read.All</code></li>
  <li><code class="language-plaintext highlighter-rouge">OnlineMeetingTranscript.Read.All</code></li>
  <li><code class="language-plaintext highlighter-rouge">Chat.Read</code></li>
  <li><code class="language-plaintext highlighter-rouge">ChannelMessage.Read.All</code></li>
  <li><code class="language-plaintext highlighter-rouge">ExternalItem.Read.All</code></li>
</ul>

<p>Steps:</p>

<ol>
  <li>In the <a href="https://entra.microsoft.com">Microsoft Entra admin center</a>, register a new application.</li>
  <li>Add a <strong>Web</strong> redirect URI: <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code>.</li>
  <li>Under <strong>API permissions</strong>, add the seven delegated permissions above and grant admin consent.</li>
  <li>Under <strong>Certificates &amp; secrets</strong>, create a client secret. Record the Application (client) ID and secret value.</li>
  <li>Set the client ID in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> (<code class="language-plaintext highlighter-rouge">clientId</code>) and provide the client secret on the connector’s <strong>Security</strong> tab after deployment.</li>
</ol>

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

<p>A known PAC CLI issue blocks OAuth <code class="language-plaintext highlighter-rouge">connectionParameters</code> on create, so deploy in two steps and configure OAuth in the portal:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Create the connector with the definition, properties, and script</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.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="c"># 2. In the Power Platform portal, open the connector's Security tab and set:</span><span class="w">
</span><span class="c">#    - Client ID and Client secret from your app registration</span><span class="w">
</span><span class="c">#    - Confirm the redirect URL matches https://global.consent.azure-apim.net/redirect</span><span class="w">
</span></code></pre></div></div>

<h2 id="telemetry">Telemetry</h2>

<p><code class="language-plaintext highlighter-rouge">script.csx</code> includes an Application Insights hook (<code class="language-plaintext highlighter-rouge">LogToAppInsights</code>) that emits events for requests, Graph calls, MCP tool calls, and errors. It’s disabled by default—the instrumentation key is a placeholder, and telemetry is skipped until you set a real key. Replace the <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> constant to turn it on. Telemetry failures are swallowed and never block an operation.</p>

<h2 id="limitations">Limitations</h2>

<ul>
  <li><strong>Beta API</strong> — subject to change and not supported for production by Microsoft.</li>
  <li><strong>Delegated only</strong> — no application (app-only) permission support.</li>
  <li><strong>License required</strong> — each signing-in user needs a Microsoft 365 Copilot license.</li>
  <li><strong>No streaming</strong> — the connector uses the synchronous <code class="language-plaintext highlighter-rouge">chat</code> endpoint, so <code class="language-plaintext highlighter-rouge">chatOverStream</code> isn’t exposed.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/api/ai-services/chat/overview">Microsoft 365 Copilot Chat API overview</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/api/ai-services/chat/copilotroot-post-conversations">Create copilotConversation</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/api/ai-services/chat/copilotconversation-chat">copilotConversation: chat</a></li>
</ul>

<p>Full source is in the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Chat">SharingIsCaring repository</a>.</p>

<p>#PowerPlatform #CopilotStudio #MCP #CustomConnectors #PowerAutomate #GraphAPI</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Copilot" /><category term="MCP" /><category term="Custom Connectors" /><category term="Power Platform" /><category term="Copilot Studio" /><category term="Microsoft Graph" /><category term="Power Automate" /><summary type="html"><![CDATA[Power Platform custom MCP connector for the Microsoft Graph beta Copilot Chat API. Send prompts to Microsoft 365 Copilot, run multi-turn conversations, add grounding context, and expose MCP tools for Copilot Studio.]]></summary></entry><entry><title type="html">Azure AI Image Search connector: multimodal embeddings meet Power Platform</title><link href="https://troystaylor.com/power%20platform/mcp/2026-07-20-azure-ai-image-search-connector.html" rel="alternate" type="text/html" title="Azure AI Image Search connector: multimodal embeddings meet Power Platform" /><published>2026-07-20T15:00:00+00:00</published><updated>2026-07-20T15:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/mcp/azure-ai-image-search-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/mcp/2026-07-20-azure-ai-image-search-connector.html"><![CDATA[<p>Azure AI Search already handles text. But what about searching your own image collections with natural language — “sunset over mountains” or “diagram showing network topology” — and getting actual image thumbnails back for an agent to reason over?</p>

<p>This MCP connector wraps Azure AI Search multimodal embeddings in a dual-mode Power Platform connector. Copilot Studio agents get MCP tools that return <code class="language-plaintext highlighter-rouge">ImageContent</code> blocks. Power Automate flows get typed operations with full schemas. Both share the same FastAPI + FastMCP backend running on Azure Container Apps.</p>

<p>Inspired by Pamela Fox’s <a href="https://techcommunity.microsoft.com/blog/azuredevcommunityblog/beyond-text-returning-images-and-interactive-apps-from-mcp-servers/4535865">Beyond text: Returning images and interactive apps from MCP servers</a> and the <a href="https://github.com/Azure-Samples/image-search-aisearch">Azure-Samples/image-search-aisearch</a> reference implementation.</p>

<h2 id="architecture">Architecture</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────────────┐
│  Power Platform / Copilot Studio                                │
│  ┌──────────────────┐  ┌──────────────────────────────────────┐ │
│  │  Power Automate  │  │  Copilot Studio (MCP)                │ │
│  │  Typed ops:      │  │  Tools: image_search,                │ │
│  │  SearchImages    │  │         search_by_image,             │ │
│  │  SearchByImage   │  │         get_image_details,           │ │
│  │  GetImageDetails │  │         display_images               │ │
│  │  GetImageUrl     │  │                                      │ │
│  │  UploadImage     │  │                                      │ │
│  └────────┬─────────┘  └──────────────────┬───────────────────┘ │
└───────────┼───────────────────────────────┼─────────────────────┘
            │        Custom Connector       │
            └───────────────────┬───────────┘
                                │ HTTPS + X-API-Key
            ┌───────────────────▼────────────────────┐
            │  Azure Container Apps (FastAPI + MCP)  │
            │  • /health — liveness probe            │
            │  • /mcp — MCP endpoint                 │
            │  • /api/search — text search           │
            │  • /api/search-by-image — reverse      │
            │  • /api/images/{f} — details           │
            │  • /api/images/{f}/url — SAS           │
            │  • /api/upload — add images            │
            └──────────────┬──────────┬──────────────┘
                           │          │
          ┌────────────────▼──┐  ┌────▼──────────────────┐
          │  Azure AI Search  │  │  Azure Blob Storage   │
          │  (multimodal      │  │  (image files)        │
          │   embeddings)     │  │                       │
          └───────────────────┘  └───────────────────────┘
</code></pre></div></div>

<h2 id="mcp-tools">MCP tools</h2>

<p>When connected via Copilot Studio, the server exposes four tools:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">image_search</code></td>
      <td>Search by natural language query, returns thumbnails + metadata</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">search_by_image</code></td>
      <td>Find visually similar images by providing an image URL</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">get_image_details</code></td>
      <td>Full metadata and larger preview for a specific image</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">display_images</code></td>
      <td>Render selections as ImageContent blocks for the agent/user</td>
    </tr>
  </tbody>
</table>

<p>The agent workflow follows a two-stage pattern:</p>

<ol>
  <li><strong>Search</strong> — <code class="language-plaintext highlighter-rouge">image_search</code> returns thumbnails (resized to 512×512 for token-efficient model inspection) plus structured metadata</li>
  <li><strong>Curate</strong> — The agent reviews results, selects the best matches</li>
  <li><strong>Display</strong> — <code class="language-plaintext highlighter-rouge">display_images</code> renders selections in an interactive carousel via an MCP App resource</li>
</ol>

<h2 id="typed-operations-power-automate">Typed operations (Power Automate)</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Method</th>
      <th>Path</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SearchImages</td>
      <td>POST</td>
      <td>/api/search</td>
    </tr>
    <tr>
      <td>SearchByImage</td>
      <td>POST</td>
      <td>/api/search-by-image</td>
    </tr>
    <tr>
      <td>GetImageDetails</td>
      <td>GET</td>
      <td>/api/images/{filename}</td>
    </tr>
    <tr>
      <td>GetImageUrl</td>
      <td>GET</td>
      <td>/api/images/{filename}/url</td>
    </tr>
    <tr>
      <td>UploadImage</td>
      <td>POST</td>
      <td>/api/upload</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">SearchImages</code> accepts a natural language query and returns results with base64 thumbnails and AI-generated descriptions. <code class="language-plaintext highlighter-rouge">GetImageUrl</code> returns a time-limited SAS URL for full-resolution download.</p>

<h2 id="how-the-search-works">How the search works</h2>

<p>The server uses hybrid retrieval — combining full-text search with vector similarity via <code class="language-plaintext highlighter-rouge">VectorizableTextQuery</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">results</span> <span class="o">=</span> <span class="n">search_client</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span>
    <span class="n">search_text</span><span class="o">=</span><span class="n">query</span><span class="p">,</span>
    <span class="n">top</span><span class="o">=</span><span class="n">max_results</span><span class="p">,</span>
    <span class="n">vector_queries</span><span class="o">=</span><span class="p">[</span>
        <span class="nc">VectorizableTextQuery</span><span class="p">(</span>
            <span class="n">k_nearest_neighbors</span><span class="o">=</span><span class="n">max_results</span><span class="p">,</span>
            <span class="n">fields</span><span class="o">=</span><span class="sh">"</span><span class="s">embedding</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">text</span><span class="o">=</span><span class="n">query</span><span class="p">,</span>
        <span class="p">)</span>
    <span class="p">],</span>
    <span class="n">select</span><span class="o">=</span><span class="p">[</span><span class="sh">"</span><span class="s">metadata_storage_path</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">metadata_storage_name</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">verbalized_image</span><span class="sh">"</span><span class="p">],</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The index uses an integrated vectorizer (<code class="language-plaintext highlighter-rouge">aiServicesVision</code>) so queries are auto-vectorized at search time — no client-side embedding calls needed. If the vector search fails (wrong index schema, missing vectorizer), it falls back gracefully to text-only search.</p>

<p>For reverse image search, <code class="language-plaintext highlighter-rouge">VectorizableImageUrlQuery</code> vectorizes an image URL and finds nearest neighbors in embedding space:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">results</span> <span class="o">=</span> <span class="n">search_client</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span>
    <span class="n">search_text</span><span class="o">=</span><span class="sh">""</span><span class="p">,</span>
    <span class="n">top</span><span class="o">=</span><span class="n">max_results</span><span class="p">,</span>
    <span class="n">vector_queries</span><span class="o">=</span><span class="p">[</span>
        <span class="nc">VectorizableImageUrlQuery</span><span class="p">(</span>
            <span class="n">url</span><span class="o">=</span><span class="n">image_url</span><span class="p">,</span>
            <span class="n">k_nearest_neighbors</span><span class="o">=</span><span class="n">max_results</span><span class="p">,</span>
            <span class="n">fields</span><span class="o">=</span><span class="sh">"</span><span class="s">embedding</span><span class="sh">"</span><span class="p">,</span>
        <span class="p">)</span>
    <span class="p">],</span>
<span class="p">)</span>
</code></pre></div></div>

<h2 id="lightweight-mode">Lightweight mode</h2>

<p>Not every deployment needs Blob Storage access. Set <code class="language-plaintext highlighter-rouge">LIGHTWEIGHT_MODE=true</code> to return URLs and metadata only — no image bytes fetched, no thumbnails generated. Useful when the consumer (a flow or agent) only needs the image URL for downstream processing.</p>

<h2 id="index-requirements">Index requirements</h2>

<p>The AI Search index needs:</p>

<ul>
  <li>An <code class="language-plaintext highlighter-rouge">embedding</code> field (<code class="language-plaintext highlighter-rouge">Collection(Edm.Single)</code>, 1024 dimensions, HNSW profile)</li>
  <li>A <code class="language-plaintext highlighter-rouge">verbalized_image</code> field (AI-generated text description per image)</li>
  <li>A <code class="language-plaintext highlighter-rouge">metadata_storage_name</code> field (blob filename)</li>
  <li>A <code class="language-plaintext highlighter-rouge">metadata_storage_path</code> field (full blob URL)</li>
  <li>An integrated vectorizer using <code class="language-plaintext highlighter-rouge">aiServicesVision</code></li>
</ul>

<p>The <a href="https://github.com/Azure-Samples/image-search-aisearch">Azure-Samples/image-search-aisearch</a> repo includes a complete indexing pipeline you can adapt.</p>

<h2 id="deployment">Deployment</h2>

<h3 id="build-in-acr">Build in ACR</h3>

<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">acr</span><span class="w"> </span><span class="nx">build</span><span class="w"> </span><span class="nt">--registry</span><span class="w"> </span><span class="nx">yourregistry</span><span class="w"> </span><span class="nt">--image</span><span class="w"> </span><span class="nx">ai-image-search:latest</span><span class="w"> </span><span class="nt">--file</span><span class="w"> </span><span class="nx">server/Dockerfile</span><span class="w"> </span><span class="nx">server/</span><span class="w">
</span></code></pre></div></div>

<p>No local Docker required — ACR builds remotely.</p>

<h3 id="deploy-infrastructure">Deploy infrastructure</h3>

<p>The <code class="language-plaintext highlighter-rouge">infra/</code> folder contains Bicep templates. Deploy with:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$acrPwd</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">az</span><span class="w"> </span><span class="nx">acr</span><span class="w"> </span><span class="nx">credential</span><span class="w"> </span><span class="nx">show</span><span class="w"> </span><span class="nt">--name</span><span class="w"> </span><span class="nx">yourregistry</span><span class="w"> </span><span class="nt">--query</span><span class="w"> </span><span class="s2">"passwords[0].value"</span><span class="w"> </span><span class="nt">-o</span><span class="w"> </span><span class="nx">tsv</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">deployment</span><span class="w"> </span><span class="nx">group</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--resource-group</span><span class="w"> </span><span class="nx">rg-ai-image-search</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--template-file</span><span class="w"> </span><span class="nx">infra/main.bicep</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--parameters</span><span class="w"> </span><span class="nx">containerImage</span><span class="o">=</span><span class="s2">"yourregistry.azurecr.io/ai-image-search:latest"</span><span class="w"> </span><span class="se">`</span><span class="w">
                 </span><span class="n">apiKey</span><span class="o">=</span><span class="s2">"your-api-key"</span><span class="w"> </span><span class="se">`</span><span class="w">
                 </span><span class="n">acrServer</span><span class="o">=</span><span class="s2">"yourregistry.azurecr.io"</span><span class="w"> </span><span class="se">`</span><span class="w">
                 </span><span class="n">acrUsername</span><span class="o">=</span><span class="s2">"yourregistry"</span><span class="w"> </span><span class="se">`</span><span class="w">
                 </span><span class="n">acrPassword</span><span class="o">=</span><span class="nv">$acrPwd</span><span class="w">
</span></code></pre></div></div>

<p>This deploys an Azure Container Apps environment + app, Azure AI Search (Basic tier), and a Storage account with an images container.</p>

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

<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">-df</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">-pf</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">-sf</span><span class="w"> </span><span class="nx">script.csx</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-e</span><span class="w"> </span><span class="nx">c4f149b0-9f42-e8c4-97d8-bc69b59f971c</span><span class="w">
</span></code></pre></div></div>

<p>Create a connection using the API key from deployment output.</p>

<h2 id="how-it-differs-from-azure-mcp-servers-ai-search-tools">How it differs from Azure MCP Server’s AI Search tools</h2>

<p>Azure MCP Server already provides <a href="https://learn.microsoft.com/azure/developer/azure-mcp-server/tools/azure-ai-search">AI Search tools</a> (<code class="language-plaintext highlighter-rouge">search index query</code>, <code class="language-plaintext highlighter-rouge">search knowledge base retrieve</code>), but those return raw JSON document fields — no image handling. This connector adds:</p>

<ul>
  <li>Fetches actual image bytes and returns <code class="language-plaintext highlighter-rouge">ImageContent</code> blocks</li>
  <li>Resizes images to thumbnails for token-efficient model inspection</li>
  <li>Supports reverse image search via <code class="language-plaintext highlighter-rouge">VectorizableImageUrlQuery</code></li>
  <li>Generates time-limited SAS URLs for full-resolution download</li>
  <li>Provides an upload endpoint to grow the collection</li>
  <li>Includes an MCP App carousel resource for interactive display</li>
</ul>

<h2 id="use-cases">Use cases</h2>

<p><strong>Product catalog search</strong> — A Copilot Studio agent searches product images by description (“red running shoes size 10”) and presents matching products in a carousel.</p>

<p><strong>Digital asset management</strong> — Power Automate flows search a brand asset library by concept (“corporate headshot outdoors”) and return SAS URLs for direct download.</p>

<p><strong>Visual similarity matching</strong> — An agent accepts an uploaded image and finds visually similar items in the collection — useful for “find more like this” workflows.</p>

<p><strong>Content moderation preview</strong> — Before publishing, a flow searches for recently uploaded images, retrieves thumbnails for human review, and routes approvals.</p>

<p>The full source is available in the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Azure%20AI%20Image%20Search">SharingIsCaring repository</a>.</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="MCP" /><category term="Azure AI Search" /><category term="MCP" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="Power Automate" /><category term="Azure Container Apps" /><category term="Multimodal Embeddings" /><category term="Image Search" /><summary type="html"><![CDATA[A dual-mode Power Platform custom connector for natural language image search over your own image collections. Uses Azure AI Search multimodal embeddings for semantic retrieval and Azure Blob Storage for image delivery.]]></summary></entry><entry><title type="html">Ranger: a Scout-alternative Copilot Cowork plugin</title><link href="https://troystaylor.com/power%20platform/mcp/2026-07-17-ranger-cowork-plugin-scout-alternative.html" rel="alternate" type="text/html" title="Ranger: a Scout-alternative Copilot Cowork plugin" /><published>2026-07-17T15:00:00+00:00</published><updated>2026-07-17T15:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/mcp/ranger-cowork-plugin-scout-alternative</id><content type="html" xml:base="https://troystaylor.com/power%20platform/mcp/2026-07-17-ranger-cowork-plugin-scout-alternative.html"><![CDATA[<p>Microsoft Scout shipped in Frontier preview with browser automation, code execution, persistent memory, and M365 access. It’s impressive — but it requires a GitHub Copilot Business or Enterprise license on top of your M365 Copilot license, runs only on desktop, needs Intune policy configuration, and routes LLM interactions through external AI models via the GitHub Copilot SDK.</p>

<p>Ranger delivers the same capabilities through Copilot Cowork using only M365 Copilot Credits you’ve already purchased. No additional licensing. No desktop app. No Intune enrollment. It works anywhere Cowork works — web, Teams, and mobile. You do need a small Azure footprint (a Container App and ACA Sandboxes — roughly $15-30/mo plus pay-per-use compute), but no per-user seat cost beyond what you’re already paying for M365 Copilot.</p>

<h2 id="the-licensing-difference">The licensing difference</h2>

<p>Scout’s <a href="https://learn.microsoft.com/microsoft-scout/get-started#prerequisites">prerequisites on Microsoft Learn</a> explicitly require both:</p>

<ol>
  <li><strong>Microsoft 365 Copilot license</strong> — access to M365 Copilot</li>
  <li><strong>GitHub Copilot Business or Enterprise license</strong> — LLM processing through the GitHub Copilot SDK</li>
</ol>

<p>The <a href="https://learn.microsoft.com/microsoft-scout/microsoft-scout-responsible-ai-faq#how-does-microsoft-scout-handle-my-data">Responsible AI FAQ</a> confirms: “LLM interactions in Microsoft Scout are processed through GitHub Copilot, which operates under separate terms. In those cases, prompts, content, and related data may be transmitted outside Microsoft 365, including to third-party model providers configured through GitHub Copilot.”</p>

<p>Cowork plugins consume M365 Copilot Credits through <a href="https://learn.microsoft.com/microsoft-365/copilot/usage-based-billing-overview-copilot-credits">usage-based billing</a> — the same credits your organization already manages for other M365 Copilot features. No separate GitHub license needed. Data stays within your M365 compliance boundary.</p>

<h2 id="what-ranger-provides">What Ranger provides</h2>

<p>28 MCP tools organized across 9 skills:</p>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Tools</th>
      <th>Implementation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Browser automation</td>
      <td>9 Playwright tools</td>
      <td>Cloud Playwright in ACA Sandboxes</td>
    </tr>
    <tr>
      <td>Code execution</td>
      <td>Python, bash, JS, TS, .NET, R</td>
      <td>Isolated microVMs with 31 pre-installed packages</td>
    </tr>
    <tr>
      <td>Documents</td>
      <td>Word, Excel, PowerPoint</td>
      <td>python-docx, openpyxl, python-pptx</td>
    </tr>
    <tr>
      <td>Memory</td>
      <td>save, recall, list</td>
      <td>In-memory (Cosmos DB with private endpoint for prod)</td>
    </tr>
    <tr>
      <td>OneDrive</td>
      <td>save, read, list, delete</td>
      <td>Graph OBO — full CRUD</td>
    </tr>
    <tr>
      <td>Email and Teams</td>
      <td>send email, post message</td>
      <td>Graph OBO</td>
    </tr>
    <tr>
      <td>Calendar</td>
      <td>list, create, free/busy, delete</td>
      <td>Graph OBO</td>
    </tr>
    <tr>
      <td>Internet search</td>
      <td>browser-based Bing</td>
      <td>No API key required</td>
    </tr>
    <tr>
      <td>Parallel execution</td>
      <td>run_parallel</td>
      <td>Concurrent Promise.all</td>
    </tr>
  </tbody>
</table>

<h2 id="architecture">Architecture</h2>

<pre><code class="language-mermaid">graph LR
    A[M365 Copilot] --&gt; B[Cowork Plugin - Ranger]
    B --&gt; C[MCP Server - Azure Container App]
    C --&gt; D[ACA Sandboxes - Browser]
    C --&gt; E[ACA Sandboxes - Code]
    C --&gt; F[Microsoft Graph OBO]
    C --&gt; G[In-process cron scheduler]
</code></pre>

<p>The MCP server runs on Azure Container Apps with a system-assigned managed identity. Browser and code execution happen in isolated ACA Sandbox microVMs — each session gets a fresh Linux VM with no shared state. M365 access uses Graph On-Behalf-Of flow with explicit scopes for OneDrive, Email, Teams, and Calendar.</p>

<p>The server uses plain JSON-RPC over HTTPS (not SSE). Cowork requires this transport for tool injection.</p>

<h2 id="how-it-compares-to-scout">How it compares to Scout</h2>

<table>
  <thead>
    <tr>
      <th>Aspect</th>
      <th>Scout</th>
      <th>Ranger</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Licensing</td>
      <td>M365 Copilot + GitHub Copilot license</td>
      <td>M365 Copilot Credits only</td>
    </tr>
    <tr>
      <td>LLM routing</td>
      <td>GitHub Copilot SDK (external models)</td>
      <td>M365 Copilot (internal)</td>
    </tr>
    <tr>
      <td>Form factor</td>
      <td>Desktop app (Windows/macOS)</td>
      <td>Cowork plugin (web, Teams, mobile)</td>
    </tr>
    <tr>
      <td>Admin setup</td>
      <td>Frontier enrollment + Intune policy + attestation</td>
      <td>M365 Admin Center app deployment</td>
    </tr>
    <tr>
      <td>Browser</td>
      <td>Local Playwright</td>
      <td>Cloud Playwright in ACA Sandboxes</td>
    </tr>
    <tr>
      <td>Code execution</td>
      <td>Local shell</td>
      <td>Isolated microVMs (6 languages, 31 packages)</td>
    </tr>
    <tr>
      <td>Automations</td>
      <td>Heartbeat + scheduled (desktop must be on)</td>
      <td>In-process cron (24/7, no desktop)</td>
    </tr>
    <tr>
      <td>Multi-user</td>
      <td>Single desktop user</td>
      <td>Any M365 Copilot user in the org</td>
    </tr>
    <tr>
      <td>Governance</td>
      <td>Intune</td>
      <td>M365 Admin Center + Defender</td>
    </tr>
    <tr>
      <td>Data residency</td>
      <td>Data transmitted outside M365 via GitHub</td>
      <td>Stays within M365 compliance boundary</td>
    </tr>
  </tbody>
</table>

<h2 id="azure-resources">Azure resources</h2>

<table>
  <thead>
    <tr>
      <th>Resource</th>
      <th>Purpose</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Container App (minReplicas: 1)</td>
      <td>MCP server</td>
      <td>~$15-30/mo</td>
    </tr>
    <tr>
      <td>ACA Sandboxes</td>
      <td>Per-session compute (browser/code)</td>
      <td>Pay-per-use</td>
    </tr>
    <tr>
      <td>Entra App Registration</td>
      <td>OAuth + OBO for Graph</td>
      <td>Free</td>
    </tr>
  </tbody>
</table>

<h2 id="key-technical-insights">Key technical insights</h2>

<p>A few things I learned building this:</p>

<ol>
  <li><strong>Cowork requires plain JSON-RPC</strong> — the MCP SDK’s <code class="language-plaintext highlighter-rouge">StreamableHTTPServerTransport</code> returns SSE, which Cowork can parse for discovery but won’t inject tools from. You need a raw JSON-RPC handler.</li>
  <li><strong>OAuth client registration</strong> (not SSO) in Teams Developer Portal is required for <code class="language-plaintext highlighter-rouge">tools/call</code> to work.</li>
  <li><strong>devPreview schema</strong> with no <code class="language-plaintext highlighter-rouge">mcpToolDescription</code> enables dynamic tool discovery.</li>
  <li><strong>OBO with explicit scopes</strong> — using <code class="language-plaintext highlighter-rouge">.default</code> only returns <code class="language-plaintext highlighter-rouge">User.Read</code>. You must list individual scopes (Files.ReadWrite.All, Mail.Send, etc.).</li>
  <li><strong>readOnlyHint: true</strong> on tools skips the user confirmation prompt in Cowork.</li>
  <li><strong>Disk images</strong> for custom sandbox environments are created via the <code class="language-plaintext highlighter-rouge">azure-containerapps-sandbox</code> Python SDK with <code class="language-plaintext highlighter-rouge">registry_credentials</code>.</li>
</ol>

<h2 id="deployment">Deployment</h2>

<p>The deployment is four steps: build the container image, assign RBAC, register OAuth in Teams Developer Portal, and upload the plugin package. Full instructions are in the repository.</p>

<h2 id="source">Source</h2>

<p>The complete implementation — MCP server, skill definitions, custom sandbox Dockerfiles, and deployment scripts — is in the <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/Ranger">Cowork Plugins/Ranger</a> folder of my SharingIsCaring repository.</p>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="MCP" /><category term="Cowork" /><category term="MCP" /><category term="Microsoft Scout" /><category term="ACA Sandboxes" /><category term="M365 Copilot" /><category term="Azure Container Apps" /><category term="Playwright" /><summary type="html"><![CDATA[A Copilot Cowork plugin that delivers Microsoft Scout-equivalent functionality — browser automation, code execution, document generation, M365 integration, and scheduled automations — using M365 Copilot Credits instead of a separate GitHub Copilot license.]]></summary></entry></feed>