Entra ID callers on MCP
Besides API keys, the MCP server at /beacon/mcp accepts Microsoft Entra ID bearer tokens. This is how an agent platform calls a Beacon that is embedded in a host application. It supports two modes, and both are first-class:
| Mode | Token | Identified by |
|---|---|---|
| User | A delegated token issued on behalf of the user (it carries scp) | The user’s oid, plus roles and groups |
| System | An app-only token from the client-credentials flow (idtyp=app, or no scp + roles + oid == sub) | The calling app’s azp / appid |
| System (service user) | A token for a dedicated Entra service user | The service user’s oid |
The token only proves who is calling. What the caller may do (which projects, and which scope) comes from Beacon configuration. A token cannot grant itself projects or a scope: any allowed_projects, scope, auth_method, api_key_id or caller_* claim inside the token is discarded.
How a call is resolved
Section titled “How a call is resolved”-
The bearer token is validated (signature against the JWKS endpoint, issuer, audience and lifetime). The JWKS is cached: it is fetched once and refreshed hourly, or earlier (at most once every 5 minutes) when a token is signed with a key id the cached set does not hold (key rotation). A failed refresh keeps the last good keys.
-
On
/beacon/mcp, Beacon passes the validated claims toIMcpCallerMapper. The defaultConfiguredMcpCallerMapperfirst rejects the token unless:- its
tidis one ofAllowedTenants(a token withouttidis rejected); - its
audis one of the bearer audiences Beacon validates (checked again here, as defense in depth); - it is an access token of a known kind (see Token classification); ID tokens and tokens that cannot be classified are rejected.
It then checks, in this order:
- A configured system whose
ObjectIdequals the token’soid(a service user or a service principal). - A configured system whose
ClientIdequalsazp/appid, but only for app-only tokens. A delegated user token (or an ID token) issued to the same app also carries itsazp, so it never matches aClientIdsystem. - User mode, for delegated tokens, when it is enabled and the caller has one of the required roles or groups
(or
AllowAnyTenantUseris set).
- its
-
A matched caller gets the Beacon claims that API keys already use:
allowed_projects,scope(ReadorExecute), and the Beacon user id as the name identifier when the user is provisioned. It also getscaller_kindandcaller_hash. -
The route’s Execute-scope policy then applies to JWT callers exactly as it does to API keys.
Readcallers and unknown callers get403, andExecutecallers pass.
A caller that is neither a configured system nor an accepted user is unknown. It gets no projects and no scope, and it is rejected. This fails closed.
The mapped principal carries auth_method=mcp_caller, so the Execute-scope rules for scoped callers apply to it as they do to API keys. A browser session (cookie or SSO) is not a scoped caller and reaches no project on /beacon/mcp; browsers use the MCP Playground.
What a caller reaches
Section titled “What a caller reaches”The configured projects are what a caller is mapped to. What it reaches in them is decided on every call by Beacon’s project access, as for API keys and signed-in users:
| Caller | Reaches |
|---|---|
| System | its configured projects, restricted or not |
User with a Beacon user (AutoProvision) | its configured projects within its Beacon user’s access: open projects, and a restricted project only while the user is a member of it, at the lower of its membership role and its Beacon roles |
| User with a disabled or archived Beacon user | nothing |
User without a Beacon user (user management off, or AutoProvision off) | its configured open projects only |
A membership granted or revoked, a project restricted, or a user disabled takes effect on the caller’s next call. Provisioned users get no membership automatically: add them as members of the restricted projects they are configured for (DefaultRoleName gives them their global role, Viewer by default).
What a user caller with a Beacon user may do in a project follows that user’s role there, capped by the caller’s scope: the catalog tools (get_context, search, get_documentation, get_query_context) need Read; ask, query, dry_run and feedback work ad hoc and need an Editor role there: the user’s global role Editor or above and, in a restricted project, an Editor membership (or project admin). A user provisioned with the default Viewer role therefore reads the catalog and runs saved-query tools, but its SQL tools answer “Access denied: you may read project N but not run ad-hoc queries in it.” (audited). Raise DefaultRoleName, or the user’s role, to let it run SQL. System callers, and user callers without a Beacon user, are capped by their scope only (Execute: SQL tools allowed).
Provisioning therefore changes what the same person can do. A user caller without a Beacon user acts within its configured open projects with the SQL tools (its Execute scope allows them); once provisioned, it gets its stored role instead. With the default Viewer, this is the catalog only. Both are in the administrator’s hands: the caller configuration (its projects and scope) decides the first, DefaultRoleName and the user’s roles the second. When user callers are configured and DefaultRoleName is Viewer, Beacon logs this once at startup (Information).
Other routes (/beacon/api/*) accept a bearer token only when it names an existing, enabled, external Beacon
user by its subject (sub) and issuer, or a pre-registered user’s subject when only one issuer is configured. A user
that MCP user provisioning created (keyed on oid within the tenant) gets no REST session from a token. The
session is that user, with the user’s Beacon roles; no token claim (roles included) reaches it. Any other token gets
401 with WWW-Authenticate: Bearer error="invalid_token". A configured system identity therefore reaches only
/beacon/mcp, where its projects and scope apply.
On /beacon/mcp the principal carries only the caller’s name, e-mail and display name from the token, plus what the
mapper decides: token roles, groups, beacon:* claims and the token’s own subject never reach it. Until first-run
setup has created the super admin, AutoProvision creates no user and such a caller is rejected.
Token classification
Section titled “Token classification”“No scp” alone does not make a token app-only: an ID token, or any other token without scp, carries the
requesting app’s azp too and would otherwise be taken for that app’s system identity. The mapper therefore needs a
positive signal:
| Token | Classified as |
|---|---|
Has nonce | ID token → rejected |
idtyp = app and no scp | App-only |
idtyp = app and scp | Contradictory → rejected |
Has scp | Delegated (user) |
No scp, has roles, and oid == sub (a service principal’s own token) | App-only |
Anything else (e.g. neither scp nor roles) | Rejected |
Entra app registration requirement. For every system that calls with the client-credentials flow, either add the
optional access-token claim idtyp to Beacon’s app registration (Token configuration → Add optional claim →
Access → idtyp; recommended), or assign the calling app at least one app role on Beacon’s app registration so
its tokens carry roles. A client-credentials token with neither is rejected.
Configuration
Section titled “Configuration”Bearer JWT validation has to be on. In the sample host, turn it on by setting Beacon:Authentication:Oidc:Enabled and Beacon:Authentication:Oidc:McpJwksEndpoint; the sample then validates the issuer against the OIDC Authority and the audience against the OIDC ClientId, which may not match the v1/v2 issuer and audience pairs Entra puts in its tokens. A host that configures Callers usually sets ValidIssuers and ValidAudiences itself, as below. In your own host, call AddBeaconJwtAuthentication(x => { x.EnableBearerAuthentication = true; ... }) and app.UseBeaconJwtBearerAuthentication(). The token audience must be Beacon’s app registration.
Issuer and audience are mandatory once any caller is configured. Entra’s JWKS signs tokens for every tenant and
every resource, so a valid signature proves nothing about who the token is for. When Users:Enabled is on or any
Systems entry exists, the host refuses to start unless bearer JWT authentication is enabled, Validation.ValidIssuer
(or ValidIssuers) and Validation.ValidAudience (or ValidAudiences) are set, and ValidateIssuer /
ValidateAudience are left on:
builder.Services.AddBeaconJwtAuthentication(x =>{ x.EnableBearerAuthentication = true; x.Validation.JwksEndpoint = "https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys"; x.Validation.ValidIssuers = [ "https://login.microsoftonline.com/<tenant-id>/v2.0", // v2 access tokens "https://sts.windows.net/<tenant-id>/" // v1 access tokens ]; x.Validation.ValidAudiences = ["api://<beacon-app-client-id>", "<beacon-app-client-id>"];});The callers are configured under Beacon:Mcp:Callers:
{ "Beacon": { "Mcp": { "Callers": { "AllowedTenants": ["<entra-tenant-id>"], // required once any caller is configured "Users": { "Enabled": true, "RequiredRoles": ["Beacon.Mcp.User"], // any-of, combined with RequiredGroups "RequiredGroups": ["<entra-group-object-id>"], // empty + empty fails startup... "AllowAnyTenantUser": false, // ...unless this is set to true explicitly "ProjectIds": [1], // granted to every accepted user "GroupProjects": { // group id or app role -> more projects (additive) "<entra-group-object-id>": [2, 3], "Beacon.Finance": [4] }, "Scope": "Execute", // Read | Execute "AutoProvision": true, // find or create a Beacon user keyed on oid "DefaultRoleName": "Viewer" // global role for a newly provisioned user }, "Systems": [ { "Name": "agent-platform", "ClientId": "<agent-platform-app-client-id>", // app-only token (azp / appid) "ProjectIds": [1, 2], "Scope": "Execute", "HostClaims": [ { "Type": "permission", "Value": "ViewReports" } ] }, { "Name": "routine-runner", "ObjectId": "<service-user-object-id>", // dedicated Entra service user (oid) "ProjectIds": [2], "Scope": "Execute" } ] } } }}| Setting | Default | Meaning |
|---|---|---|
AllowedTenants | empty | Entra tenant ids (tid) whose tokens may be mapped. Required and non-empty once Users:Enabled is on or any system is configured |
Users:Enabled | false | Accept delegated user tokens at all |
Users:RequiredRoles / RequiredGroups | empty | Any-of across both lists. Both empty fails startup unless AllowAnyTenantUser is true |
Users:AllowAnyTenantUser | false | Explicit opt-in to admit every user of the allowed tenants when no role or group is required |
Users:ProjectIds | empty | Projects every accepted user is configured for; a restricted one is reached only by its members (see What a caller reaches) |
Users:GroupProjects | empty | Additional configured projects per group object id or app role, added together |
Users:Scope | Execute | Read callers cannot reach /beacon/mcp |
Users:AutoProvision | true | Create or find a Beacon user (ExternalId = oid, provider entra:{tid}) so audit rows carry a user and project memberships apply. A disabled or archived Beacon user is rejected; within the one minute a caller’s user is cached, its calls reach no project instead. This needs user management to be enabled; without it the caller is accepted with no Beacon user |
Systems[]:ClientId / ObjectId | none | Set exactly one per entry. The host refuses to start otherwise |
Systems[]:HostClaims | empty | The permission set this identity gets inside the host application. It is carried on the caller for in-process API dispatch |
Validation runs at startup. A system entry with both ClientId and ObjectId, or with neither, a duplicate name, or a non-positive project id, stops the host with a message that names the entry. So do, once any caller is configured: an empty AllowedTenants, bearer validation without issuer or audience, and user mode with no role or group requirement and no AllowAnyTenantUser. With user mode and AutoProvision on, DefaultRoleName must be set and must not be the Admin role or the role of a functional permission (every provisioned user would administer Beacon).
With AutoProvision, two first requests from the same new user can race to create the Beacon user; the loser
re-reads the row the winner created instead of failing.
Connect Claude (claude.ai / Claude Desktop custom connector)
Section titled “Connect Claude (claude.ai / Claude Desktop custom connector)”Claude’s custom connectors connect by URL and sign the user in with OAuth. Beacon does not run an OAuth server here:
Claude gets a delegated Entra token for the user and calls /beacon/mcp with it, and Beacon validates the token
and maps the user like any other user-mode caller. Claude learns where to sign in from
Beacon’s discovery documents:
POST /beacon/mcpwithout a token answers401withWWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource".- That document names the authorization server (
authorization_servers[0], the Entra v2 issuer), the scopes to request (scopes_supported) and the resource (resource, the canonicalhttps://<host>/beacon/mcpURL). - Claude reads Entra’s metadata at
https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration, signs the user in, and requests a token for those scopes, sending the resource URL as the RFC 8707resourceparameter.
By default the document lists Beacon’s internal scope name (Execute) and no authorization server unless
Beacon’s own OIDC login is on. Neither is something Entra understands. Configure it under
Beacon:Mcp:ProtectedResource.
1. The API app registration
Section titled “1. The API app registration”This is the app registration whose tokens Beacon validates (Validation.ValidAudiences). In an embedded setup, usually
the host’s API registration.
- Manifest: set
api.requestedAccessTokenVersionto2(accessTokenAcceptedVersion: 2in the older AAD Graph manifest format). Do this first: Entra only lets a v2 app register an HTTPS Application ID URI. - Expose an API → Application ID URI: keep
api://<api-client-id>and add the exact MCP URL,https://<host>/beacon/mcp, toidentifierUris. MCP clients send that URL as theresourceparameter, and Entra does not ignore it: it must match one of the app’s Application ID URIs character for character (no trailing slash), or the token request fails withAADSTS9010010. Entra may require the host name to be a verified domain of the tenant before it accepts an HTTPS Application ID URI. - Expose an API → Add a scope:
mcp.access(delegated; admins and users, or admins only). Clients request it fully qualified,api://<api-client-id>/mcp.access. The scope and theresourcemust belong to the same app registration. - App roles (recommended): e.g.
Beacon.Mcp.User, assigned to the users or groups who may use the connector, and listed inUsers:RequiredRoles.
The token Claude receives is a v2 access token: iss is https://login.microsoftonline.com/<tenant-id>/v2.0, aud
is the API’s client id GUID (not the api:// URI and not the MCP URL), and scp is mcp.access. Keep both audience
forms in ValidAudiences so v1 callers of the same API keep working.
2. A client app registration for Claude
Section titled “2. A client app registration for Claude”Entra has no Dynamic Client Registration, so Claude cannot register itself. Create a separate registration for it:
- Authentication → Add a platform → Web, redirect URI
https://claude.ai/api/mcp/auth_callback. - Certificates & secrets: create a client secret (it is a confidential client). Note its expiry. The connector stops working when it expires.
- API permissions → Add a permission → APIs my organization uses → the API registration → Delegated →
mcp.access, then Grant admin consent for the tenant.
3. Beacon configuration
Section titled “3. Beacon configuration”builder.Services.AddBeaconJwtAuthentication(x =>{ x.EnableBearerAuthentication = true; x.Validation.JwksEndpoint = "https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys"; x.Validation.ValidIssuers = ["https://login.microsoftonline.com/<tenant-id>/v2.0"]; x.Validation.ValidAudiences = ["<api-client-id>", "api://<api-client-id>"];});{ "Beacon": { "PublicBaseUrl": "https://<host>", // resource = https://<host>/beacon/mcp "Mcp": { "ProtectedResource": { "AuthorizationServers": ["https://login.microsoftonline.com/<tenant-id>/v2.0"], // or, instead of listing it: "DeriveAuthorizationServerFromJwtIssuer": true "ScopesSupported": ["api://<api-client-id>/mcp.access"] }, "Callers": { "AllowedTenants": ["<tenant-id>"], "Users": { "Enabled": true, "RequiredRoles": ["Beacon.Mcp.User"], "ProjectIds": [1] } } } }}Setting (Beacon:Mcp:ProtectedResource) | Default | Meaning |
|---|---|---|
AuthorizationServers | empty | Issuers published as authorization_servers, verbatim and in order (clients use the first). Use the v2 issuer: it equals the issuer in Entra’s v2 metadata and the iss of the tokens. Wins over everything below |
DeriveAuthorizationServerFromJwtIssuer | false | With no explicit list, publish the bearer JWT issuer (Validation.ValidIssuer(s)). With several, the one ending in /v2.0 is chosen; templated issuers ({tenantid}) are skipped |
ScopesSupported | empty | Scopes published in scopes_supported (and the server card) instead of Execute |
With none of these set the document lists Beacon’s OIDC authority when SSO is on, and the Execute scope. An explicit authorization server without ScopesSupported, or derivation with no usable
issuer, is logged as a warning at startup. The WWW-Authenticate challenge, the resource in the metadata and the
server card’s endpoint and resource_metadata all derive from the same PublicBaseUrl (or the forwarded request
host when it is unset), so they always agree.
4. Add the connector in Claude
Section titled “4. Add the connector in Claude”- In claude.ai, Settings → Connectors → Add custom connector (an organization owner may have to add it for the
organization first). Enter the server URL,
https://<host>/beacon/mcp. - Open Advanced settings and enter the OAuth Client ID and OAuth Client Secret of the Claude client registration from step 2.
- Connect. Claude opens the Entra sign-in, the user consents (or admin consent already covers it), and the tools appear. Claude Desktop uses the connectors of the signed-in claude.ai account.
Things to check when the connection fails, none of which Beacon can see:
- PKCE advertisement. Entra’s metadata does not publish
code_challenge_methods_supported, although Entra supports PKCE (S256). The MCP authorization spec tells clients to refuse an authorization server that does not advertise it; whether Claude enforces that for Entra is for Anthropic’s docs to say. - Refresh tokens. If Claude asks the user to sign in again every hour, add
offline_accesstoScopesSupported(clients request the listed scopes verbatim). AADSTS9010010: theresourceURL Claude sent is not an Application ID URI of the API registration, or a requested scope belongs to another registration. Compare theresourcein/.well-known/oauth-protected-resourcewith the registration’sidentifierUris.401witherror="invalid_token": Beacon rejected the token: checkiss/audagainstValidIssuers/ValidAudiences(a v2 token’saudis the client id GUID). A malformed bearer value gets the same401, never a500.403: the token is valid but the user is not an accepted caller (Users:Enabled,RequiredRoles/RequiredGroups,AllowedTenants).503withRetry-After: Beacon could not fetch the Entra JWKS and has no cached keys.
Audit and privacy
Section titled “Audit and privacy”Every MCP tool call writes an audit row, as before. For a JWT caller, the row also records:
CallerKind:UserorSystem.CallerHash: the lowercase hex HMAC-SHA256 of{tid}:{subject}, where the subject is the token’soid, else itsazp/appid, else itssub. It is stable per identity and never the raw value. The HMAC key is derived for this purpose only fromBeacon:Mcp:CallerHashKeywhen it is set, else fromBeacon:EncryptionKey, so someone who holds the tenant’s list ofoids cannot reverse the hashes. WithoutCallerHashKey, changing the encryption key changes every caller’s hash, so rows written before and after the change no longer group together: before changing the encryption key, setBeacon:Mcp:CallerHashKey(a secret) to the key in use now, and the hashes stay the same (see Configuration → Replacing the Key).UserId: the provisioned Beacon user, in user mode.
Logs carry only the caller hash, never the raw oid, email or token.
Replacing the mapper
Section titled “Replacing the mapper”A host can replace the default mapping. For example, it can map an Entra user to its own admin user and permission set. To do this, register an IMcpCallerMapper before AddBeaconServices, which registers the default with TryAdd, or replace the registration afterwards:
builder.Services.AddScoped<IMcpCallerMapper, AcmeMcpCallerMapper>();The mapper receives the raw token claims (oid, tid, groups, and so on) and returns an McpCaller, or null to reject the caller. ConfiguredMcpCallerMapper is public, so a custom mapper can delegate to it and adjust the result. Inject McpCallerSubjectHasher, which AddBeaconServices registers as a singleton, to produce the same audit hash.
Upgrading from 4.5: project access
Section titled “Upgrading from 4.5: project access”- User callers need membership on restricted projects. A user caller reaches a configured project that is restricted only while its Beacon user is a member of it; open configured projects are reached as before. Add the provisioned users as members of the restricted projects they are configured for.
- User callers without a Beacon user reach only their configured open projects (before: every configured project).
- A disabled or archived user’s caller reaches nothing from its next call, also within the minute its mapping is cached.
- System callers are unchanged: they reach their configured projects, restricted or not.
- User callers with a Beacon user need Editor to run SQL tools.
ask,query,dry_runandfeedbackneed the user’s role in the project to be Editor or above (global role, and membership on a restricted project); the catalog tools need Read only. Users provisioned with the defaultDefaultRoleNameViewernow read the catalog but are refused the SQL tools: give them the Editor role (and Editor membership on restricted projects) if they should run SQL. - Host endpoint and saved-query tools follow the same access: host endpoint tools need the caller to reach the host’s project, and saved-query tools are listed only where the caller may run saved SQL.
- New projects are restricted. A project created after the upgrade by an active Beacon user is restricted, so a user caller configured for it reaches it only once its provisioned Beacon user is a member, with an Editor membership to run SQL tools. Checklist for provisioned users: the default
Viewerrole reads the catalog only; restricted configured projects need membership; the SQL tools need Editor (global role and membership).
Upgrading from 4.3
Section titled “Upgrading from 4.3”The gateway release changes how JWT callers reach MCP. Before upgrading a host that accepts bearer tokens:
- JWT callers on
/beacon/mcpnow needBeacon:Mcp:Callers. A validated token alone no longer reaches MCP: without a matching system or accepted user the caller gets403. ConfigureSystems/Usersfor every agent platform that calls MCP with a token. API keys are unaffected. - Reserved claims are stripped from tokens on every route.
allowed_projects,scope,auth_method,api_key_idandcaller_*inside a token are discarded on/beacon/api/*too, so a token can no longer carry its own project restriction or scope. - New mandatory settings once any caller is configured:
Beacon:Mcp:Callers:AllowedTenants, bearerValidation.ValidIssuer(s)andValidation.ValidAudience(s)with validation on, and eitherRequiredRoles/RequiredGroupsorUsers:AllowAnyTenantUser = truefor user mode. The host fails at startup with a message that names what is missing. - App-only tokens need a positive signal: the
idtypoptional claim or an app role (see Token classification). - New migrations, in both providers:
AddMcpAuditCallerIdentity(audit caller kind/hash),AddHostManagedDataSources,AddProjectImportedDocuments,AddQueryMcpToolsandAddHostManagedProjects(Project.HostManagedKey). Apply them before the new version starts.
Hosts whose cookie authentication redirects
Section titled “Hosts whose cookie authentication redirects”A host whose MVC cookie authentication answers an unauthenticated request with a 302 to its login page breaks MCP clients: an MCP
client cannot follow that, and an agent then sees an HTML login page instead of an auth error. Make the cookie
events return status codes for the MCP path:
options.Events.OnRedirectToLogin = ctx =>{ if (ctx.Request.Path.StartsWithSegments("/beacon/mcp")) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }
ctx.Response.Redirect(ctx.RedirectUri); return Task.CompletedTask;};options.Events.OnRedirectToAccessDenied = ctx =>{ if (ctx.Request.Path.StartsWithSegments("/beacon/mcp")) { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; }
ctx.Response.Redirect(ctx.RedirectUri); return Task.CompletedTask;};