Connect ToolHive to Microsoft Entra ID
This guide covers the full setup for connecting a
VirtualMCPServer to Microsoft Entra ID. App
Roles are the standard Entra mechanism for application-level access control, and
this guide uses them to place role values directly in the roles claim of the
access token.
What you'll need
Collect these values as you complete the steps below:
- Application (client) ID
- Client Secret
- Tenant ID
- Application ID URI (e.g.
api://<client-id>) - Issuer URL:
https://login.microsoftonline.com/{tenant-id}/v2.0 - Redirect URI:
https://<your-vmcp-endpoint>/oauth/callback
Configure Entra ID
Step 1: Register an application
- Entra ID > App registrations > New registration
- Name: e.g.
toolhive-engineering - Supported account types: Single tenant
- Redirect URI: platform Web, URI
https://<your-vmcp-endpoint>/oauth/callback - Note the Application (client) ID and Directory (tenant) ID
The redirect URI must be an exact match - no wildcards, no trailing slashes.
CLI equivalent
TENANT_ID=$(az account show --query tenantId -o tsv)
DISPLAY_NAME="toolhive-engineering"
REDIRECT_URI="https://<your-vmcp-endpoint>/oauth/callback"
# --sign-in-audience: AzureADMyOrg = single-tenant
# Alternatives: AzureADMultipleOrgs, AzureADandPersonalMicrosoftAccount
APP=$(az ad app create \
--display-name "$DISPLAY_NAME" \
--sign-in-audience AzureADMyOrg \
--web-redirect-uris "$REDIRECT_URI" \
--query "{appId:appId, id:id}" \
-o json)
APP_ID=$(echo $APP | jq -r .appId)
OBJECT_ID=$(echo $APP | jq -r .id)
echo "APP_ID=$APP_ID"
echo "OBJECT_ID=$OBJECT_ID"
echo "TENANT_ID=$TENANT_ID"
APP_ID = Application (client) ID. OBJECT_ID = Graph object ID (needed for
PATCH calls). These are different values.
Step 2: Expose an API
By default, Entra issues access tokens for Microsoft Graph, not for your app.
App Roles only appear in tokens where your app is the audience. Exposing a
custom scope under api://<client-id>/ forces the embedded auth server to
request a token with your app as the audience.
- App registrations > your app > Expose an API
- Click Add next to "Application ID URI" - accept the default
api://<client-id> - Click Add a scope:
- Scope name:
mcp.access - Who can consent: Admins and users
- Admin consent display name: "Access MCP Servers"
- Admin consent description: "Allow access to ToolHive MCP servers"
- User consent display name: "Access MCP Servers"
- User consent description: "Allow access to ToolHive MCP servers"
- State: Enabled
- Scope name:
CLI equivalent
# 2a: Set Application ID URI
az ad app update \
--id "$APP_ID" \
--identifier-uris "api://$APP_ID"
# 2b: Add the mcp.access scope
SCOPE_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
az rest \
--method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \
--headers "Content-Type=application/json" \
--body "{
\"api\": {
\"oauth2PermissionScopes\": [
{
\"id\": \"$SCOPE_ID\",
\"adminConsentDescription\": \"Allow access to ToolHive MCP servers\",
\"adminConsentDisplayName\": \"Access MCP Servers\",
\"userConsentDescription\": \"Allow access to ToolHive MCP servers\",
\"userConsentDisplayName\": \"Access MCP Servers\",
\"isEnabled\": true,
\"type\": \"User\",
\"value\": \"mcp.access\"
}
]
}
}"
oauth2PermissionScopes is a full-replacement array. To add more scopes later,
include existing scopes with their original UUIDs. Set type to "Admin" to
require admin consent for this scope.
Step 3: Require assignment
By default, any user in your tenant can authenticate to the app (they just won't have any roles). To restrict access to explicitly assigned users only:
- Enterprise applications > your app > Properties
- Set Assignment required? to Yes > Save
Without this setting, unassigned users can still obtain tokens. They will have
no roles claim and be denied by Cedar, but the experience (successful login
followed by 403) is avoidable.
CLI equivalent
# The portal creates the service principal automatically;
# the CLI requires it explicitly before you can set properties on it.
SP_OBJECT_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv)
az rest \
--method PATCH \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID" \
--headers "Content-Type=application/json" \
--body '{"appRoleAssignmentRequired": true}' # false = unassigned users can still authenticate (just get 403 from Cedar)
Step 4: Create app roles
- App registrations > your app > App roles > Create app role
- Create each role:
| Display name | Value | Description | Allowed member types |
|---|---|---|---|
| MCP Developers | mcp-developers | Developer access to MCP tools | Users/Groups |
| MCP Platform | mcp-platform | Platform/SRE access to MCP tools | Users/Groups |
| MCP Admin | mcp-admin | Administrative access to all MCP tools | Users/Groups |
For machine-to-machine scenarios, set Allowed member types to Applications.
CLI equivalent
# All three roles must be set in a single call (full replacement).
ROLE_DEVELOPERS_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
ROLE_PLATFORM_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
ROLE_ADMIN_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
az ad app update \
--id "$APP_ID" \
--app-roles "[
{
\"id\": \"$ROLE_DEVELOPERS_ID\",
\"allowedMemberTypes\": [\"User\"],
\"displayName\": \"MCP Developers\",
\"description\": \"Developer access to MCP tools\",
\"value\": \"mcp-developers\",
\"isEnabled\": true
},
{
\"id\": \"$ROLE_PLATFORM_ID\",
\"allowedMemberTypes\": [\"User\"],
\"displayName\": \"MCP Platform\",
\"description\": \"Platform/SRE access to MCP tools\",
\"value\": \"mcp-platform\",
\"isEnabled\": true
},
{
\"id\": \"$ROLE_ADMIN_ID\",
\"allowedMemberTypes\": [\"User\"],
\"displayName\": \"MCP Admin\",
\"description\": \"Administrative access to all MCP tools\",
\"value\": \"mcp-admin\",
\"isEnabled\": true
}
]"
allowedMemberTypes: ["User"] covers both users and groups in assignments.
"Group" is not a separate Graph API value; the portal's "Users/Groups" option
maps to ["User"]. Use ["Application"] for M2M/service principal assignments.
Step 5: Assign users and groups to roles
- Enterprise applications > your app > Users and groups > Add user/group
- Select users or security groups
- Select the role (e.g.
mcp-developers) - Entra defaults to "Default Access" if you skip this step, which will not match your Cedar policies - Click Assign
- Roles appear in the
rolesclaim on next sign-in
CLI equivalent
# For a standard managed-tenant user:
USER_OID=$(az ad user show --id "user@yourdomain.com" --query id -o tsv)
# For the signed-in account (tenant owner or personal Microsoft account):
# USER_OID=$(az ad signed-in-user show --query id -o tsv)
# For a guest/external user, their UPN uses the #EXT# format:
# USER_OID=$(az ad user show \
# --id "user_externaldomain.com#EXT#@yourtenant.onmicrosoft.com" \
# --query id -o tsv)
# Assign user to mcp-developers role
az rest \
--method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID/appRoleAssignedTo" \
--headers "Content-Type=application/json" \
--body "{
\"principalId\": \"$USER_OID\",
\"resourceId\": \"$SP_OBJECT_ID\",
\"appRoleId\": \"$ROLE_DEVELOPERS_ID\"
}"
resourceId must be the service principal object ID (SP_OBJECT_ID), not the
application client ID (APP_ID). Use ROLE_PLATFORM_ID or ROLE_ADMIN_ID in
appRoleId to assign other roles.
Step 6: Create a client secret
- App registrations > your app > Certificates & secrets > Client secrets > New client secret
- Set an expiry (the example below uses 1 year)
- Copy the Value immediately - it is shown only once and cannot be retrieved later
CLI equivalent
SECRET=$(az ad app credential reset \
--id "$APP_ID" \
--display-name "toolhive-vmcp-secret" \
--years 1 \
--append \
--query password \
-o tsv)
echo "CLIENT_SECRET=$SECRET" # Store immediately - shown only once
Use --append or this command deletes all existing credentials. The
--query password flag extracts the secret value (password is the Graph API
field name for the generated credential).
Optional: Configure additional token claims
If you want Cedar policies to reference the user's name or email, or want human-readable names in audit logs:
- App registrations > your app > Token configuration > Add optional claim >
select "ID" > check
email,given_name,family_name> Add - When prompted to add Microsoft Graph permissions, click Yes
This is not required for group-based access control. The roles claim is
already present in the access token from step 2.
CLI equivalent
az ad app update \
--id "$APP_ID" \
--optional-claims '{
"idToken": [
{"name": "email", "essential": false},
{"name": "given_name", "essential": false},
{"name": "family_name", "essential": false}
],
"accessToken": [],
"saml2Token": []
}'
| Item | Value |
|---|---|
| Scopes requested from Entra | api://<client-id>/mcp.access openid profile email offline_access |
| Access token claims produced | roles: ["mcp-developers"], sub, iss, aud: "api://<client-id>" |
Consistency checklist
Role values must match exactly (case-sensitive) in three places:
- App role Value field (step 4): App registrations > your app > App roles >
Value (e.g.
mcp-developers) - Role assignment (step 5): Enterprise applications > Users and groups > the role selected during assignment
- Cedar policies (see Deploy to ToolHive):
THVGroup::"mcp-developers"must match the app role Value exactly
Changing the Value in place 1 does not update existing assignments in place 2. If you rename a role, you must remove and recreate all assignments.
This Entra application is exclusively for vMCP authentication. Do not reuse an existing app registered for other services. Each VirtualMCPServer should have its own app registration.
Deploy to ToolHive
Step 1: Create the IdP client secret
kubectl create secret generic idp-client-secret \
-n <your-namespace> \
--from-literal=client-secret=<YOUR_CLIENT_SECRET>
For production, configure persistent signing keys so tokens survive pod restarts. See Configure the embedded auth server.
Step 2: Create an MCPGroup and backend MCPServers
If you haven't already set up your backends, follow the
vMCP quickstart steps 1 and 2 to create an
MCPGroup and deploy your MCPServers into it. The examples below assume a group
named engineering-tools.
Step 3: Create the VirtualMCPServer
The VirtualMCPServer ties everything together: it references the backend group, configures the embedded auth server with Entra ID as the upstream provider, sets up incoming OIDC validation, and defines Cedar policies for group-based access control.
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPOIDCConfig
metadata:
name: engineering-tools-oidc
spec:
type: inline
inline:
issuer: 'https://<your-vmcp-endpoint>'
---
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPServer
metadata:
name: engineering-tools
spec:
groupRef:
name: engineering-tools
authServerConfig:
issuer: 'https://<your-vmcp-endpoint>'
upstreamProviders:
- name: entra
type: oidc
oidcConfig:
issuerUrl: 'https://login.microsoftonline.com/<TENANT_ID>/v2.0'
clientId: '<YOUR_CLIENT_ID>'
clientSecretRef:
name: idp-client-secret
key: client-secret
redirectUri: 'https://<your-vmcp-endpoint>/oauth/callback'
scopes:
- 'api://<client-id>/mcp.access'
- openid
- profile
- email
- offline_access
incomingAuth:
type: oidc
oidcConfigRef:
name: engineering-tools-oidc
audience: 'https://<your-vmcp-endpoint>'
resourceUrl: 'https://<your-vmcp-endpoint>'
authzConfig:
type: inline
inline:
policies:
- |
permit(
principal in THVGroup::"mcp-developers",
action,
resource
);
- |
forbid(
principal in THVGroup::"mcp-developers",
action == Action::"call_tool",
resource == Tool::"delete_namespace"
);
- |
permit(
principal in THVGroup::"mcp-platform",
action,
resource
);
- |
permit(
principal in THVGroup::"mcp-admin",
action,
resource
);
outgoingAuth:
source: discovered
The highlighted lines are Entra-specific — issuerUrl uses your tenant's v2.0
endpoint, and scopes must include api://<client-id>/mcp.access to get an
access token with your app as the audience (and thus the roles claim).
- MCPOIDCConfig: validates tokens issued by the embedded auth server to
incoming MCP client requests. The
issuermust matchauthServerConfig.issuer. - groupRef: the MCPGroup containing your backend MCPServers.
- authServerConfig: configures the embedded auth server with Entra as the upstream IdP.
- incomingAuth: applies Cedar policies to each tool call. Role values in
THVGroup::"mcp-developers"must match the App Role values from step 4 exactly. - outgoingAuth:
source: discoveredmeans vMCP automatically forwards credentials to backend MCPServers that require authentication.
Next steps
Troubleshooting
Role dropdown only shows "Default Access": App Roles created on the App registration can take a moment to propagate to the Enterprise application view. A hard browser refresh (Ctrl+Shift+R) usually resolves this.
Nested groups not supported: App role assignments do not support nested groups. Only directly assigned group members receive the role claim.
Roles not appearing: The most common causes: (1) the user hasn't signed in since the role was assigned — roles update on next token issuance; (2) the user is not assigned to the app; (3) the correct role was not selected during assignment (Entra defaults to "Default Access" if you skip the role selection).