MCP Server

The bridge exposes a Model Context Protocol server so an LLM can drive it the same way a human operator does through the SPA -- same tools, same tenant-access model, same audit trail. Every tool call that changes something in a customer tenant is staged for a human to approve by default; nothing an MCP client says gets to touch Microsoft's cloud unless a person clicked Approve, unless you explicitly opt a tenant out of that.

Approvals tab listing a pending MFA reset action awaiting approval, with Approve and Reject buttons and a preview of what will change.

Transport and connecting

Streamable HTTP at POST /mcp, via the official ModelContextProtocol.AspNetCore SDK, stateless (no server-side session pinning between calls). It sits behind the same [Authorize] bearer-token gate as every REST controller -- there is no separate MCP auth stack to configure. Point an MCP client (Claude Code, Claude Desktop, or anything else that speaks Streamable HTTP) at https://your-instance/mcp with a bearer token in the Authorization header.

There is no OAuth 2.1 discovery flow (.well-known/oauth-authorization-server) yet -- a client that expects to discover and negotiate credentials itself won't work out of the box. Today you mint a credential first (see Personal access tokens below) and configure the client with it directly.

Personal access tokens

MCP clients are machine callers, not interactive browser sessions, so they authenticate with a personal access token (PAT) instead of a normal login -- a long-lived, individually revocable JWT you mint from the Security tab (Auth:Mode=Local only; PATs are tied to local accounts, the same way tenant sharing is). A PAT carries the same identity claims your login token would, plus a jti that McpTokenValidator checks against McpToken.RevokedAt on every request -- revoking a token from the Security tab takes effect on its next call, no waiting for it to expire.

A PAT can only reach /mcp. Dedicated middleware rejects any jti-bearing request outside that path with 403 -- a stolen or leaked PAT cannot be used to call the REST API directly, register a new passkey, or reach anything an interactive login token could. This check is scoped to Auth:Mode=Local specifically, since a standard OIDC access token (Entra ID and most providers) commonly carries its own unrelated jti claim that has nothing to do with this app's PATs.

The tool catalog

Seven tools today, covering read access and the one mutating action (workflow remediation):

ToolDoes
who_am_iIdentity the server resolved for this call -- useful for a client to confirm it's authenticated as the account it expects.
list_tenantsCustomer tenants the caller has access to, with GDAP delegation status. Scoped by the same per-tenant roles as the SPA.
get_dashboardThe same triage view as the SPA's landing page: stats, needs-attention items, recent runs.
list_workflowsThe known-fix workflow catalog (MFA reset, password reset, license repair, mailbox archive, etc.) with each one's required inputs.
diagnose_workflowRuns a workflow's read-only diagnosis. Never mutates anything -- safe to call regardless of the tenant's approval mode.
remediate_workflowRuns a workflow's fix. This is the one tool that can change something in a tenant -- see Approval modes below for what actually happens when it's called.
check_pending_actionPolls a staged action's current status and any execution error, so a client that just staged something in Queue mode can find out what happened to it.

Every tool is annotated with MCP's readOnlyHint/destructiveHint metadata, so a well-behaved client can tell remediate_workflow apart from the other six without guessing from its name.

Approval modes: Queue and ClientTrust

Each tenant has an McpApprovalMode, defaulting to Queue. This is the whole point of the human-in-the-loop design:

ModeWhat remediate_workflow does
Queue (default) Re-runs the workflow's diagnosis as a preview, then stages the fix as a PendingAction instead of running anything -- returns the pending action's id. Nothing in the tenant changes until a human approves it from the Approvals tab. The preview includes the actual input values (which user, which mailbox, etc.), not just the workflow's name, so an approver can tell what they're approving.
ClientTrust Runs immediately, same as if a human had clicked "Fix" in the SPA. For a tenant you've decided an MCP client can be trusted to act on unattended -- a deliberate per-tenant opt-out, not a default.
Switching a tenant to ClientTrust requires the Automation policy manager instance role (or Administrator) (PATCH /api/admin/tenants/{id}/mcp-mode) -- there's no SPA toggle yet, so it's an API call today. This is deliberately a narrow instance safety-policy permission, not a tenant-operational bypass: it grants no ability to view or act on that tenant's data. See Authentication for the separate instance-role and tenant-role models.

The approval workflow

A staged action shows up in the Approvals tab (and the Dashboard's "needs attention" tile) for anyone with Operator+ access to that tenant. Approving or rejecting is an atomically claimed state transition -- two people clicking Approve on the same action at the same moment can't both succeed, closing a real double-execution race the state machine was built to prevent. If an approved action's execution fails (a Graph error, an expired GDAP relationship, whatever), it stays Approved with the error attached and shows up as retryable, rather than silently vanishing or double-counting as both failed and re-queued.

Every claim, audit, and execution-outcome transition writes an explicit AuditEvent in the same transaction as the state change itself, so a crash mid-transition can't leave a status change with no audit record behind it.

Access control

MCP tools use the exact same ITenantAccessService the REST API does -- there is no separate MCP permission model. list_tenants, get_dashboard, and the workflow tools all resolve the caller's real tenant grants; check_pending_action requires Viewer+ on the staged action's tenant before it will say anything about it.