New: SCIM 2.0 provisioning from Microsoft Entra ID
Role sync has always run at sign-in. That is enough to widen or narrow what someone may do, and useless for taking access away: a person who has stopped signing in never triggers it, and the MCP API key sitting in their .env keeps working indefinitely. The gap was not in enforcement — getAllowedToolIds is evaluated on every request — it was that nothing told AnythingMCP the directory had changed.
This release adds that channel. Entra pushes joiners, movers and leavers over SCIM 2.0, and the change lands without a sign-in.
Self-hosted only. Every SCIM route answers 404 on AnythingMCP Cloud, for the same reason SSO does: a shared deployment would let any tenant point a workspace at an arbitrary directory and provision accounts from it.
Users
Create, update, deactivate, reactivate and delete. DELETE is deprovisioning, not erasure — the account row survives, because hard-deleting it would dissolve the audit trail, tool-invocation attribution and the admin-count checks at exactly the moment an investigation would want them, and a user restored in the directory would come back as a second account.
Deactivation is one step and it is thorough:
- the workspace membership is deactivated,
- every MCP API key the user holds is deactivated,
- existing sessions and dashboard tokens are invalidated.
The last admin of a workspace is protected here as everywhere else: the attempt is refused, audited as LAST_ADMIN_PROTECTION_TRIGGERED, and answered with a 409 so it appears in Entra's own provisioning log.
Groups, and roles that move on their own
Provisioned groups appear under Role mappings with a from SCIM badge, pre-filled with the real display name and ready for their MCP roles. A membership change reaches tools/list within seconds of Entra pushing it — no sign-in, no cache to wait out.
Once SCIM describes a user, the groups claim in their sign-in token is ignored. That is deliberate: SCIM holds the whole membership set, whereas a token can silently drop it past ~150 groups, and letting the token win would let an absent claim revoke access SCIM had just granted. A user SCIM has never described still falls back to the claim.
In the dashboard
Settings → Single sign-on → Provisioning (SCIM). Enable, copy the Tenant URL and the token (shown once), rotate, disable. Live counters for users provisioned and groups synced, when Entra last called, and a Resync roles now button to re-evaluate every SCIM-known member after editing mappings.
Fixes
Three bugs, all found by running provisioning against a live Entra tenant rather than against our own assumptions. Each is a fact about Entra that no unit test could have told us.
externalIdno longer breaks every update. A non-gallery Entra application shipsexternalIdmapped tomailNickname, notobjectId. We answered400 mutabilityto any value that did not match our anchor — and since Entra packs the entire user into a singlePatchOp, that one rejection discarded the display name, the department and, critically,active. Deprovisioning, the whole point of the feature, silently did nothing, while Entra's Provision on demand view reported four green ticks. The mismatched value is now ignored and recorded asexternalIdIgnoredon the audit event instead of being fatal.- Role sync no longer claims to have changed something when it did not.
appliedwas hardcoded true on the matched path, so the "unchanged" branch was unreachable: every resync reported all N members as changed and wrote oneROLE_SYNC_APPLIEDper user even when the directory had not moved. On a provider of any size, a single mapping edit buried the syncs that mattered under hundreds of identical audit rows. - A repeated group
DELETEis no longer an error. Entra retries a delete it did not see acknowledged; the retry got a404for a group that was already gone and surfaced as a failure.
Behaviour changes
ROLE_SYNC_APPLIEDis written only when a sync actually changes something. A re-run over an unchanged directory reportsapplied: 0, unchanged: Nin theROLE_SYNC_BATCH_COMPLETEDsummary and writes no per-user event. A refused last-admin demotion still counts as applied and is still audited — the directory asked for a change and was denied, which is what an auditor needs to see.- A repeated
DELETE /api/scim/v2/Groups/:idanswers204instead of404. - A SCIM
PATCHcarrying a mismatchedexternalIdis applied instead of rejected.
Upgrade notes
Two additive migrations, 20260909090000_add_scim_provisioning and 20260909100000_add_scim_groups. Nothing is dropped or rewritten, and provisioning is inert until an admin enables it per provider.
If you already run SSO with role sync, nothing changes until you turn SCIM on. When you do, read docs/scim-entra-setup.md first — two things on the Entra side are not guessable:
- provisioning needs a second, non-gallery application. The app you registered for sign-in came from App registrations, and Entra shows "automatic provisioning … is not supported" with Get started greyed out on those;
externalIdmust be re-mapped toobjectId. Left at itsmailNicknamedefault, the same person arrives twice — once from provisioning, once from sign-in.
Verification
4024 tests (3969 pass, 55 skip), plus an end-to-end run against a live Entra tenant over a public tunnel:
| Step | Result |
|---|---|
| Test connection | filtered GET /Users → 200, empty ListResponse |
| Provision on demand, user | PATCH /Users/:id → 200; the existing SSO identity was adopted — scim_managed_at set, external_subject untouched, no duplicate account |
| Provision on demand, group | GET /Groups?excludedAttributes=members → membership PATCH → SCIM_GROUP_MEMBERSHIP_CHANGED |
| Role sync on that change | applied: 0, unchanged: 1 — the counter fix, on the real path |
| Throttle | X-Ratelimit-Limit: 1000, so an initial cycle is not rate-limited |
Entra's quirks were exercised for real, not only in tests: application/scim+json, capitalised op, active as the string "False", path-less replace with an object value, filtered paths such as addresses[type eq "work"].streetAddress, excludedAttributes=members, and the userName eq "<guid>" connection probe.
One gap, stated plainly: the full deprovisioning loop through Entra — disable in the directory, watch the API key return 401 — was not run, because it needs a throwaway directory account. The behaviour itself is verified at the API level.
Documentation
- docs/scim-entra-setup.md — new: click-by-click Entra setup, what a healthy run looks like on the wire, and a troubleshooting table built from the things that actually went wrong.
- docs/sso.md §6 — what is synced and what is not, the exact deprovisioning semantics, how roles behave between sign-ins, and how to turn it all off.
Full changelog: v0.4.4...v0.5.0