Microsoft Entra ID sign-in and per-tool, role-based access control in front of our MCP servers.
Overview
Microsoft Entra ID sign-in and per-tool, role-based access control in front of our MCP servers. - Claude finds the gateway's OAuth endpoints on its own, registers itself, and sends the user to Entra ID. Conditional Access (MFA, compliant device, sign-in frequency) applies like it does for any other Entra app. - The role-to-tool mapping lives in policy.yaml, so each access change is a reviewed commit. - tools/list is filtered per user. A tools/call outside the user's roles is refused before it reaches the backend, and the refusal names the role that would allow it. - Every sign-in, refresh and tool call is logged as one JSON line, with the user, tool, decision and arguments (secret-looking values masked). - Roles are read from Entra again at every token refresh (hourly by default). A disabled account, revoked session or removed role locks the user out within the hour. - Each MCP server keeps its own static token. Only the gateway knows it, and only on the internal network. 1.
README
mcp-gateway
Microsoft Entra ID sign-in and per-tool, role-based access control in front of our MCP servers.
Claude ──OAuth 2.1──▶ mcp-gateway ──(internal Docker network, backend token)──▶ hudu-mcp
│ │ ├─▶ unifi-mcp
sign-in ◀────┘ └─ checks Entra app roles on every ├─▶ portainer-mcp
(Microsoft Entra ID) tools/list and tools/call └─▶ …
- One sign-in for every connector. Claude finds the gateway’s OAuth endpoints on its own, registers itself, and sends the user to Entra ID. Conditional Access (MFA, compliant device, sign-in frequency) applies like it does for any other Entra app.
- App roles decide which tools a user can use. The role-to-tool mapping lives in
policy.yaml, so each access change is a reviewed commit. - Users only see tools they may call.
tools/listis filtered per user. Atools/calloutside the user’s roles is refused before it reaches the backend, and the refusal names the role that would allow it. - Audit trail. Every sign-in, refresh and tool call is logged as one JSON line, with the user, tool, decision and arguments (secret-looking values masked).
- Offboarding takes effect quickly. Roles are read from Entra again at every token refresh (hourly by default). A disabled account, revoked session or removed role locks the user out within the hour.
- Backends stay as they are. Each MCP server keeps its own static token. Only the gateway knows it, and only on the internal network.
How access is decided
For each request to https:////mcp:
- The gateway access token must be valid, unexpired, and issued for this connector. A token
obtained without a
resourceworks on every connector, but roles still apply. - The user must hold at least one of the connector’s roles. If not, the request gets
403. - Every JSON-RPC message is inspected:
tools/list: forwarded, then the response is filtered to the tools the user’s roles allow.tools/call: allowed only if one of the user’s roles allows that tool with those arguments.initialize,ping,notifications/*andlogging/setLevelpass through. Anything else (for exampleresources/*orprompts/*) is refused unless the connector lists it underallowMethods.- In a JSON-RPC batch, one denied call rejects the whole batch.
- Allowed requests are forwarded with the backend’s own credential. The user’s token is never
forwarded. The
X-MCP-Gateway-Userheader tells the backend who is calling, for its logs.
The servers’ own safeguards still apply behind the gateway: writes are off unless
*_ENABLE_WRITES is set, and write tools need a preview followed by confirm=true.
Entra ID setup
-
App registrations → New registration
- Name:
MCP Gateway - Supported account types: Accounts in this organizational directory only
- Redirect URI: platform Web,
https:///auth/callback
- Name:
-
Certificates & secrets → New client secret. Put the value in
ENTRA_CLIENT_SECRET. Set a reminder for its expiry. -
API permissions. Microsoft Graph delegated
openid,profile,email,offline_access(User.Readis there by default). Then Grant admin consent. -
App roles. Generate the role definitions from the policy:
npm run app-roles -- policy.yaml > approles.jsonPaste the array into the manifest’s
appRoles(Manifest blade), or create the roles by hand under App roles. Use Users/Groups as the member type, and make the Value match the policy exactly (for exampleHudu.Read). Role ids are derived from the value, so regenerating after adding roles keeps the existing ids. -
Enterprise applications → MCP Gateway → Properties → Assignment required? = Yes. Users who aren’t assigned can’t sign in at all.
-
Enterprise applications → MCP Gateway → Users and groups. Assign groups to roles, for example Techs L1 →
Hudu.Read,UniFi.Read; Techs L2 → alsoHudu.Write,AutoElevate.Approve; Admins → the*.Writeroles. Assigning roles to groups needs Entra ID P1. Without it, assign users directly. -
Optional: target the app with a Conditional Access policy (MFA, compliant device, sign-in frequency).
Take ENTRA_TENANT_ID (Directory ID, a GUID) and ENTRA_CLIENT_ID (Application ID) from the
app’s Overview page.
Policy
policy.yaml lists each connector, its backend, and what each role may call:
connectors:
portainer:
upstream: http://portainer-mcp:3000/mcp
upstreamToken: ${PORTAINER_MCP_TOKEN} # the backend's MCP_AUTH_TOKEN, from the environment
roles:
Portainer.Read:
- "portainer_list_*" # glob on the tool name
Portainer.Write:
- "*"
- "!portainer_container_exec" # exclusion: applies to this role only
Portainer.Exec:
- portainer_container_exec
"pattern"allows tools whose name matches.*matches any characters."!pattern"removes tools from that role, even if another rule in the same role allows them. It does not affect the user’s other roles.{tool, where}allows a tool only when every listed argument matches one of the given values (case-insensitive, surrounding whitespace ignored).nullmeans the argument is omitted, which matters when the backend defaults it (for examplemethod: [GET, null]where the server defaults to GET). A"!value"entry excludes matching values, as in the shipped CIPP rulewhere: { name: ["List*", "Get*", "!ListGraphBulkRequest"] }. Don’t use conditions to allowlist URL paths: backends decode%2e%2eand similar after the gateway has checked the string.- A user gets the union of all their roles. Anything not allowed is denied and hidden.
${VAR}and${VAR:-default}read the gateway’s environment. A missing variable stops startup.- Changes take effect on restart. The image ships the repo’s
policy.yaml; mount a file over/app/policy.yaml(or setPOLICY_FILE) to override it.
The shipped policy covers all ten servers with a Read and a Write role each. A few sensitive capabilities are split into their own roles:
| Role | What it adds |
|---|---|
Hudu.Passwords |
Reading stored passwords (get_asset_passwords*). Neither Read nor Write includes it. |
AutoElevate.Approve |
Approving and denying elevation requests, without the other write tools |
Portainer.Exec |
portainer_container_exec (a shell in a container). Write doesn’t include it. |
CIPP.Read |
Only CIPP’s List* / Get* endpoints through cipp_exec_tool, except ListGraphBulkRequest and ListExoRequest, which run caller-chosen Graph requests / Exchange cmdlets |
Read roles don’t include the generic *_api_request tools: a GET through them reaches
every endpoint the backend’s service account can read, secrets included (Docker container
archives, Kubernetes secrets, settings). Hudu.Read also excludes get_export*.
Portainer.Write and Portainer.Exec are administrator roles for the whole gateway. Anyone
who can change stacks or open a shell in a container on the host running mcp-gateway can
read GATEWAY_SECRET and mint tokens with any role. Grant them only to people you’d give
everything, or keep the gateway out of reach of portainer-mcp: run it on a host or
Portainer environment that portainer-mcp’s API key can’t manage.
Deploy
-
Shared network. Run
docker network create mcpon the Docker host. Then add each MCP server’s stack to it:services: hudu-mcp: # … networks: [mcp] # and remove the `ports:` section: only the gateway should reach it networks: mcp: external: trueThe upstream URLs in
policy.yamluse the container names (http://hudu-mcp:8080/mcp, …). Override any of them with_UPSTREAMif yours differ. -
Backend settings.
- Keep each server’s
MCP_AUTH_TOKENand give the same value to the gateway (HUDU_MCP_TOKEN, …). - netbird-mcp: set
NETBIRD_ENABLE_OAUTH=falseand give the gateway a NetBird PAT (NETBIRD_API_TOKEN). It’s sent asx-netbird-token. - Python servers (crashplan, dropsuite, veeamone, veeamspc): if
MCP_ALLOWED_HOSTSis set, add the internal name, for examplecrashplan-mcp:8000.
- Keep each server’s
-
Gateway stack. In Portainer go to Stacks → Add stack, paste
portainer-stack.yml, and fill in the variables (see.env.example). GenerateGATEWAY_SECRETwithopenssl rand -hex 32. The plain-HTTP port is published on127.0.0.1only. Put the reverse proxy on the same host or themcpnetwork, or setMCP_BIND=0.0.0.0if it runs elsewhere and firewall the port. The image is published toghcr.io/riccanet/mcp-gatewayby GitHub Actions on every push tomain. -
Reverse proxy. Publish the gateway over HTTPS at
PUBLIC_BASE_URL. Disable response buffering so streamed (SSE) responses flow (nginx:proxy_buffering off;). Allow long reads, since some tool calls take minutes. -
Check it.
curl https:///healthzshould return{"status":"ok"}. The startup log lists every connector URL with its roles.
Connect from Claude
In Claude, go to Settings → Connectors → Add custom connector and add one connector per
backend you use, with URL https:////mcp (for example
https://mcp.example.com/hudu/mcp). Leave the OAuth client fields empty: Claude registers
itself. Click Connect and sign in with your work account.
If you lack a role for that connector, the sign-in page says so and no connection is made. Tools you aren’t allowed to use don’t appear. Calling one anyway returns a message naming the role it needs.
Afterwards, remove the old direct connectors (the ones that used MCP_AUTH_TOKEN or a
?token= URL), and stop publishing the backends’ ports.
Configuration
| Variable | Default | |
|---|---|---|
PUBLIC_BASE_URL |
required | Public HTTPS origin, no path |
ENTRA_TENANT_ID |
required | Directory (tenant) ID, a GUID |
ENTRA_CLIENT_ID |
required | Application (client) ID |
ENTRA_CLIENT_SECRET |
required | Client secret |
GATEWAY_SECRET |
required | At least 32 characters. Signs and encrypts everything the gateway issues. |
GATEWAY_SECRET_PREVIOUS |
Comma-separated old secrets that are still accepted, for rotation | |
POLICY_FILE |
/app/policy.yaml |
Policy path |
ACCESS_TOKEN_TTL |
3600 |
Access token lifetime in seconds. It’s also the longest a removed role or disabled account keeps working. |
REFRESH_TOKEN_TTL |
2592000 |
Seconds without use before the user must sign in again (Entra policies can end it sooner) |
OAUTH_ALLOWED_REDIRECT_URIS |
https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback |
Exact callback URLs OAuth clients may register |
OAUTH_ALLOW_LOOPBACK_REDIRECTS |
true |
Also allow http://localhost/127.0.0.1/[::1] callbacks on any port (Claude Code, Claude Desktop) |
UPSTREAM_TIMEOUT_MS |
300000 |
Per-request timeout towards a backend |
MAX_BODY_BYTES |
10485760 |
Largest accepted request body |
AUDIT_LOG_ARGS |
true |
Include tool arguments, with secrets masked, in the audit log |
TRUST_PROXY |
loopback, linklocal, uniquelocal |
Express trust proxy, so rate limits see the client’s IP behind your reverse proxy |
ENTRA_AUTHORITY |
https://login.microsoftonline.com |
Change for national clouds |
PORT / HOST |
3000 / 0.0.0.0 |
|
LOG_LEVEL |
info |
debug, info, warn, error |
Audit log
Audit events go to stdout, one JSON object per line, with "type":"audit". Application logs
go to stderr.
{"type":"audit","ts":"…","event":"tool_call","user":"[email protected]","oid":"…","connector":"hudu","tool":"delete_companies_id","decision":"deny","args":"{\"id\":42}"}
{"type":"audit","ts":"…","event":"tool_call","user":"[email protected]","oid":"…","connector":"unifi","tool":"unifi_list_devices","decision":"allow","role":"UniFi.Read","status":200,"ms":412}
Events are login, login_denied, refresh, refresh_denied, tool_call and
request_denied. Arguments under keys that look like passwords, secrets, tokens or keys are
replaced with [redacted], and the arguments field is capped at 2,000 characters.
Security notes and limits
- No database. Client registrations are sealed into the
client_id. Refresh tokens are sealed (AES-256-GCM) and carry the user’s Entra refresh token. Access tokens are signed JWTs. Redeploys don’t sign anyone out. ChangingGATEWAY_SECRETwithout listing the old value inGATEWAY_SECRET_PREVIOUSsigns everyone out. - Run a single replica. A sign-in in progress travels encrypted in the
statesent to Entra, so starting sign-ins costs the gateway no memory. Only the authorization code (issued after a successful Entra sign-in, redeemed by Claude within seconds) is held in memory, so a second replica would need sticky routing for/auth/callbackand/token. - Refresh-token reuse isn’t detected, because nothing is stored. Each refresh still goes to Entra, so a revoked session or disabled account fails there.
- Only known clients can register. Redirect URIs must be one of the exact
OAUTH_ALLOWED_REDIRECT_URIS(or loopback). Allowing a whole host would let an attacker register a client that redirects through an open redirect on that host, send a technician a sign-in link (Entra signs them in silently), and receive the authorization code. - Authentication comes first. Requests to
//mcpare authenticated before the body is read, so anonymous callers can’t make the gateway parse large bodies. - Sign-in error pages don’t echo Entra’s message text, only the
AADSTScode, so a crafted callback link can’t put arbitrary text on a gateway-branded page. - Ship the audit log off the host. Anyone with Portainer.Write can also delete the container’s logs.
- The backend’s own account still does the work. Hudu, UniFi and so on see the gateway’s service credential, so the audit log here is the record of who did what. Running Microsoft calls (CIPP) with the user’s own permissions via on-behalf-of would be a later step.
- Keep backend tokens internal. The gateway is only as strong as the backends’ isolation:
remove their published ports, and turn off the token-in-URL (
/mcp/,?token=) options several of them accept.
Development
npm install
npm test # unit tests + end-to-end OAuth/proxy tests against a fake Entra and real MCP SDK backends
npm run typecheck
npm run dev # needs the environment variables above and a policy.yaml
Install
This server does not publish a one-line install command.
Open the repository installation guide