Quickstart - GitHub MCP with Entra ID
Deploy an Entra ID-authenticated GitHub MCP server, give one test user write access and another read-only access, then verify both policies with live tool calls. An optional step restricts a third user to a named list of tools.
What you'll learn
- How to register an app and define app roles in Microsoft Entra ID
- How to deploy an Entra ID-authenticated GitHub MCP server with per-server OIDC
- How to map Entra app roles to built-in ToolHive platform roles
- How to attach a per-server policy that gives one user write access and another read-only access
- How to verify the access split with live MCP tool calls
- How to narrow a role to a named allow-list of tools at bind time (optional)
Prerequisites
Before starting, make sure you have:
- A Kubernetes cluster running the Stacklok Enterprise distribution. See the Kubernetes quickstart for cluster setup, then follow the enterprise installer instructions.
kubectlconfigured against that cluster.- A Microsoft Entra ID tenant where you have permission to create an app registration, define app roles, and assign users.
- A
GitHub personal access token
(PAT) with
reposcope. The policy gates what each user can do, so the PAT is intentionally privileged. - An MCP client that can send a bearer token. This guide uses the MCP Inspector CLI, which needs Node.js.
Step 1: Set up the Entra app registration
In the Entra ID portal, create an app registration and configure it as follows:
-
App registrations > New registration. Name it
toolhive-authz-demowith single-tenant account type. -
Authentication. Configure the platform and flow you'll use to obtain user access tokens in step 6. The setup is environment-specific: for a public-client flow, for example, add Mobile and desktop applications, set a redirect URI, and set Allow public client flows to Yes.
-
Expose an API. Accept the default Application ID URI of
api://<CLIENT-ID>and add a scope namedaccess_as_user. -
App roles. Create three roles with member type Users/Groups. Use these exact Value strings - later manifests refer to them:
Display name Value MCP admin mcp-adminMCP viewer mcp-viewerMCP code reviewer mcp-reviewer -
Open the matching Enterprise application > Properties and set Assignment required to Yes, so unassigned users don't get tokens with empty
rolesclaims. -
Enterprise application > Users and groups > Add user/group. Assign one test user per role so each user receives exactly one of
mcp-admin,mcp-viewer, ormcp-reviewer. -
Note the tenant ID and application (client) ID from the app's Overview page for step 3.
Step 2: Create the namespace and GitHub PAT secret
Create a namespace to hold the demo resources and a Secret that the MCP server
will mount as the GitHub PAT.
apiVersion: v1
kind: Namespace
metadata:
name: authz-demo
apiVersion: v1
kind: Secret
metadata:
name: github-pat
namespace: authz-demo
type: Opaque
stringData:
token: '<YOUR-GITHUB-PAT>'
Replace <YOUR-GITHUB-PAT> with your classic PAT, then apply both files:
kubectl apply -f 00-namespace.yaml -f 00-secret-github-pat.yaml
The MCPServer in the next step references this Secret by name through its
secrets[] field. For the other ways ToolHive can source secrets in Kubernetes,
see
Run a server with secrets.
Step 3: Configure OIDC and deploy the GitHub MCP server
Define an MCPOIDCConfig that points at your Entra tenant, then deploy the
GitHub MCP server with a reference to that config. This MCPOIDCConfig is
per-server: it tells the proxy in front of this MCP server how to validate
incoming tokens. It is independent of the
platform-component identity
that the enterprise installer configures for the manager, UI, and Registry
Server, so you don't need that setup in place to follow this quickstart. Both
draw their group and role claims from the same identity provider.
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPOIDCConfig
metadata:
name: demo-entra-oidc
namespace: authz-demo
spec:
type: inline
inline:
issuer: 'https://login.microsoftonline.com/<TENANT-ID>/v2.0'
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPServer
metadata:
name: github-demo
namespace: authz-demo
spec:
image: ghcr.io/github/github-mcp-server:v1.0.3
transport: stdio
proxyPort: 8080
oidcConfigRef:
name: demo-entra-oidc
audience: '<CLIENT-ID>'
secrets:
- name: github-pat
key: token
targetEnvName: GITHUB_PERSONAL_ACCESS_TOKEN
env:
- name: GITHUB_API_URL
value: https://api.github.com
resources:
limits:
cpu: '200m'
memory: '256Mi'
requests:
cpu: '100m'
memory: '128Mi'
Replace <TENANT-ID> with your directory (tenant) ID and <CLIENT-ID> with the
application (client) ID from step 1, then apply both manifests and wait for the
server to come up:
kubectl apply -f 01-oidc-config.yaml -f 02-mcpserver-github.yaml
kubectl -n authz-demo wait --for=condition=Ready \
mcpserver/github-demo --timeout=2m
oidcConfigRef.audience must match the aud claim Entra puts in your access
tokens. The manifest above uses the client GUID, which is the v2 default. If
your tenant is configured to emit the Application ID URI (api://<CLIENT-ID>)
instead, set audience to that string. If you receive a 401 response, see
Troubleshooting.
This quickstart trades production hardening for a short setup path. Two choices are worth revisiting before you run this pattern in production:
- The shared PAT hides the calling user from the backend. ToolHive authenticates and authorizes each user, but every GitHub API call still reaches GitHub as the single bot identity behind the PAT. If you need GitHub to see who made each call (for its own audit log or fine-grained access), use a per-user OAuth flow to the backend instead of a shared token. See token exchange for propagating user identity to upstream services.
transport: stdioruns one process per session. It keeps the manifest simple but doesn't scale to a fleet. The GitHub MCP server also offers astreamable-httpmode, which serves many sessions from one deployment and uses OAuth rather than a PAT. See the github-mcp-server streamable-http docs.
Step 4: Apply the role bindings (admin and viewer)
Map the two Entra app roles to the built-in writer and reader
ClusterPlatformRole objects. This is the only place IdP role names appear in
the manifests, every later step refers to the platform roles by name.
apiVersion: platform.enterprise.stacklok.com/v1alpha1
kind: ClusterPlatformRoleBinding
metadata:
name: demo-admin-viewer-binding
spec:
bindings:
- roleRef:
kind: ClusterPlatformRole
name: writer
from:
- roles: ['mcp-admin']
- roleRef:
kind: ClusterPlatformRole
name: reader
from:
- roles: ['mcp-viewer']
kubectl apply -f 10-trb-admin-viewer.yaml
This ClusterPlatformRoleBinding grants the writer role to callers whose JWT
carries roles: ["mcp-admin"] and the reader role to callers whose JWT
carries roles: ["mcp-viewer"]. The binding is cluster-scoped and applies after
a ToolhiveAuthorizationPolicy attaches it to a server.
Step 5: Apply the per-server policy with reader and writer
The ToolhiveAuthorizationPolicy attaches roles to a specific MCP server. This
policy binds the unrestricted writer role and uses a toolHintFilter to limit
the reader binding to tools that declare the MCP readOnlyHint annotation.
apiVersion: toolhive.enterprise.stacklok.com/v1alpha1
kind: ToolhiveAuthorizationPolicy
metadata:
name: github-demo-rw-policy
namespace: authz-demo
spec:
targetRef:
name: github-demo
bindings:
- roleRef:
kind: ClusterPlatformRole
name: writer
- roleRef:
kind: ClusterPlatformRole
name: reader
toolHintFilter:
readOnlyHint: true
targetRef.kind defaults to MCPServer. The role defines the actions, and the
binding uses toolHintFilter to select the tools allowed at this target.
Apply the policy and wait for the operator to compile it:
kubectl apply -f 11-tap-reader-writer.yaml
kubectl wait --for=condition=Compiled tap/github-demo-rw-policy \
-n authz-demo --timeout=60s
The Compiled condition flips to True once the operator has turned the policy
into the Cedar policy bundle the proxy will enforce. If it doesn't compile, see
troubleshooting.
Step 6: Verify the policy is enforced
Point an MCP client at the server using each user's token, and call a read tool
(list_issues) and a write tool (issue_write) to see the policy in action.
For this local check, forward the proxy Service to your machine. In production, configure clients with the MCP server URL exposed through your ingress or gateway.
kubectl -n authz-demo port-forward svc/mcp-github-demo-proxy 18080:8080
The server is then reachable at http://localhost:18080/mcp, which the commands
below use.
Get a token for each test user
Obtain an access token for each test user from your identity provider: the user
assigned mcp-admin and the user assigned mcp-viewer (plus mcp-reviewer for
step 7). Use whichever OAuth 2.0 flow your environment supports - authorization
code with PKCE, device code, or a client your platform already uses. The policy
evaluates the token's contents, not how you acquired it, so each token must:
- be an access token for this app, with an audience matching
spec.oidcConfigRef.audienceon the MCPServer (theapi://<CLIENT-ID>/access_as_userscope produces it), and - carry the user's app role in the
rolesclaim, which requires the user to be assigned to that role on the enterprise application (step 1).
Decode a token with jwt decode "$TOKEN" to confirm its aud and roles
before continuing. Put each user's token in a shell variable - TOKEN_ADMIN,
TOKEN_VIEWER, and TOKEN_REVIEWER - for the calls below.
Verify with an MCP client
Use any MCP client that can send a bearer token. This guide uses the
MCP Inspector in CLI mode,
which runs the MCP handshake (initialize, notifications/initialized,
tools/list) before your call. That handshake matters here: the proxy only
learns a tool's readOnlyHint annotation once tools/list has run on the
session, and the reader binding's toolHintFilter consults it. A standard
client does this for you.
Call a read tool (list_issues) and a write tool (issue_write) with each
user's token, substituting your repository for <OWNER>/<REPO>. As the admin,
both succeed:
npx @modelcontextprotocol/inspector --cli http://localhost:18080/mcp \
--transport http --header "Authorization: Bearer $TOKEN_ADMIN" \
--method tools/call --tool-name list_issues \
--tool-arg owner=<OWNER> --tool-arg repo=<REPO>
npx @modelcontextprotocol/inspector --cli http://localhost:18080/mcp \
--transport http --header "Authorization: Bearer $TOKEN_ADMIN" \
--method tools/call --tool-name issue_write \
--tool-arg method=create --tool-arg owner=<OWNER> --tool-arg repo=<REPO> \
--tool-arg title="authz smoke test"
As the viewer, the read succeeds but the write is denied (same two commands with
$TOKEN_VIEWER):
npx @modelcontextprotocol/inspector --cli http://localhost:18080/mcp \
--transport http --header "Authorization: Bearer $TOKEN_VIEWER" \
--method tools/call --tool-name list_issues \
--tool-arg owner=<OWNER> --tool-arg repo=<REPO>
npx @modelcontextprotocol/inspector --cli http://localhost:18080/mcp \
--transport http --header "Authorization: Bearer $TOKEN_VIEWER" \
--method tools/call --tool-name issue_write \
--tool-arg method=create --tool-arg owner=<OWNER> --tool-arg repo=<REPO> \
--tool-arg title="should be denied"
Both users can read. The admin's issue_write creates the issue and returns its
URL. The viewer's issue_write is rejected by the proxy before it reaches the
server:
| User | list_issues | issue_write (create) |
|---|---|---|
mcp-admin | succeeds | succeeds (issue created) |
mcp-viewer | succeeds | denied |
The client reports the proxy's 403 response as a transport error:
Failed to call tool issue_write: Streamable HTTP error: Error POSTing to
endpoint: {"Result":null,"Error":{"code":403,"message":"Unauthorized"},"ID":{}}
The proxy rejects issue_write because it lacks the readOnlyHint annotation.
The reader binding permits list_issues, which carries the annotation.
This guide targets github-mcp-server:v1.0.3, where issue writes go through the
issue_write tool. Other versions may name tools differently. Run the client's
tools/list method to see what your server exposes.
Step 7: (Optional) Give a reviewer access to only the tools they need
List the tools a code reviewer needs in ruleRestrictions.tools on the binding.
The following policy permits reading pull requests, searching code, and listing
issues.
apiVersion: platform.enterprise.stacklok.com/v1alpha1
kind: ClusterPlatformRole
metadata:
name: code-reviewer
spec:
description: 'Code-reviewer baseline. Scoped at binding time.'
productActions:
- apiGroup: toolhive.enterprise.stacklok.com
actions:
- list_tools
- call_tool
- list_prompts
- get_prompt
- list_resources
- read_resource
apiVersion: platform.enterprise.stacklok.com/v1alpha1
kind: ClusterPlatformRoleBinding
metadata:
name: demo-reviewer-binding
spec:
bindings:
- roleRef:
kind: ClusterPlatformRole
name: code-reviewer
from:
- roles: ['mcp-reviewer']
apiVersion: toolhive.enterprise.stacklok.com/v1alpha1
kind: ToolhiveAuthorizationPolicy
metadata:
name: github-reviewer-policy
namespace: authz-demo
spec:
targetRef:
name: github-demo
bindings:
- roleRef:
kind: ClusterPlatformRole
name: code-reviewer
ruleRestrictions:
- tools:
- list_issues
- pull_request_read
- search_code
Apply the three files and wait for compilation:
kubectl apply \
-f 20-platformrole-code-reviewer.yaml \
-f 21-trb-reviewer.yaml \
-f 22-tap-code-reviewer.yaml
kubectl wait --for=condition=Compiled tap/github-reviewer-policy \
-n authz-demo --timeout=60s
Fetch a token for the mcp-reviewer user and rerun the verification commands
from step 6 with $TOKEN_REVIEWER. Every tool below is read-only, so the
difference is the binding's ruleRestrictions allow-list, not the
readOnlyHint:
| Tool | Result |
|---|---|
list_issues | allowed |
pull_request_read | allowed |
search_code | allowed |
get_file_contents | denied |
The reviewer is granted broad MCP verbs by the role, but the per-server binding
narrows the tool list to exactly the three names in ruleRestrictions.tools.
Step 8: Clean up
Deleting the namespace removes the MCPServer, OIDC config, secret, and
namespaced ToolhiveAuthorizationPolicy. The two cluster-scoped role bindings
(and the custom code-reviewer role from the optional step) live outside the
namespace, so delete them explicitly:
kubectl delete clusterplatformrolebinding \
demo-admin-viewer-binding demo-reviewer-binding --ignore-not-found
kubectl delete clusterplatformrole code-reviewer --ignore-not-found
kubectl delete namespace authz-demo
Next steps
- Delegate access grants to an application team with namespace authorization.
- See every field on the policy resource in the ToolhiveAuthorizationPolicy CRD reference.
- Understand what the operator compiles your
ToolhiveAuthorizationPolicyinto with Cedar policies.
Troubleshooting
Tokens have the wrong aud claim
Decode a fresh token with jwt decode "$TOKEN" | jq .aud. The value must match
spec.oidcConfigRef.audience on the MCPServer exactly. Entra emits the client
GUID by default; some tenants are configured to emit api://<CLIENT-ID>
instead. Update audience in 02-mcpserver-github.yaml to whatever the JWT
actually contains, then re-apply.
Tokens are missing the roles claim
If jwt decode shows .payload.roles empty, the user isn't assigned to any app
role on the enterprise application. Open the enterprise app's Users and
groups page and add an assignment for the user. You can verify with
az rest --method GET --uri "https://graph.microsoft.com/v1.0/users/<UPN>/appRoleAssignments".
A ToolhiveAuthorizationPolicy doesn't reach the Compiled condition
Inspect the resource for the failing condition and look at the operator events:
kubectl -n authz-demo describe tap github-demo-rw-policy
The most common cause is a roleRef that names a ClusterPlatformRole that
doesn't exist (a typo, or the optional step's role wasn't applied yet).
The viewer succeeds on a write tool
The reader binding's toolHintFilter reads each tool's readOnlyHint
annotation, which the proxy only caches after tools/list has run on the
session. A standard MCP client (like the Inspector) runs tools/list during the
handshake, so the filter is always in effect. If you instead script raw JSON-RPC
calls, call initialize, notifications/initialized, and tools/list before
tools/call, or the filter sees an empty cache and the write slips through.