RM

riccanet/mcp-gateway

Developer tools
0 stars 0 forks Quality 65 Trend 20

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/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.
  • 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:

  1. The gateway access token must be valid, unexpired, and issued for this connector. A token obtained without a resource works on every connector, but roles still apply.
  2. The user must hold at least one of the connector’s roles. If not, the request gets 403.
  3. 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/* and logging/setLevel pass through. Anything else (for example resources/* or prompts/*) is refused unless the connector lists it under allowMethods.
    • In a JSON-RPC batch, one denied call rejects the whole batch.
  4. Allowed requests are forwarded with the backend’s own credential. The user’s token is never forwarded. The X-MCP-Gateway-User header 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

  1. App registrations → New registration

    • Name: MCP Gateway
    • Supported account types: Accounts in this organizational directory only
    • Redirect URI: platform Web, https:///auth/callback
  2. Certificates & secrets → New client secret. Put the value in ENTRA_CLIENT_SECRET. Set a reminder for its expiry.

  3. API permissions. Microsoft Graph delegated openid, profile, email, offline_access (User.Read is there by default). Then Grant admin consent.

  4. App roles. Generate the role definitions from the policy:

    npm run app-roles -- policy.yaml > approles.json
    

    Paste 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 example Hudu.Read). Role ids are derived from the value, so regenerating after adding roles keeps the existing ids.

  5. Enterprise applications → MCP Gateway → Properties → Assignment required? = Yes. Users who aren’t assigned can’t sign in at all.

  6. Enterprise applications → MCP Gateway → Users and groups. Assign groups to roles, for example Techs L1 → Hudu.Read, UniFi.Read; Techs L2 → also Hudu.Write, AutoElevate.Approve; Admins → the *.Write roles. Assigning roles to groups needs Entra ID P1. Without it, assign users directly.

  7. 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). null means the argument is omitted, which matters when the backend defaults it (for example method: [GET, null] where the server defaults to GET). A "!value" entry excludes matching values, as in the shipped CIPP rule where: { name: ["List*", "Get*", "!ListGraphBulkRequest"] }. Don’t use conditions to allowlist URL paths: backends decode %2e%2e and 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 set POLICY_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

  1. Shared network. Run docker network create mcp on 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: true
    

    The upstream URLs in policy.yaml use the container names (http://hudu-mcp:8080/mcp, …). Override any of them with _UPSTREAM if yours differ.

  2. Backend settings.

    • Keep each server’s MCP_AUTH_TOKEN and give the same value to the gateway (HUDU_MCP_TOKEN, …).
    • netbird-mcp: set NETBIRD_ENABLE_OAUTH=false and give the gateway a NetBird PAT (NETBIRD_API_TOKEN). It’s sent as x-netbird-token.
    • Python servers (crashplan, dropsuite, veeamone, veeamspc): if MCP_ALLOWED_HOSTS is set, add the internal name, for example crashplan-mcp:8000.
  3. Gateway stack. In Portainer go to Stacks → Add stack, paste portainer-stack.yml, and fill in the variables (see .env.example). Generate GATEWAY_SECRET with openssl rand -hex 32. The plain-HTTP port is published on 127.0.0.1 only. Put the reverse proxy on the same host or the mcp network, or set MCP_BIND=0.0.0.0 if it runs elsewhere and firewall the port. The image is published to ghcr.io/riccanet/mcp-gateway by GitHub Actions on every push to main.

  4. 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.

  5. Check it. curl https:///healthz should 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. Changing GATEWAY_SECRET without listing the old value in GATEWAY_SECRET_PREVIOUS signs everyone out.
  • Run a single replica. A sign-in in progress travels encrypted in the state sent 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/callback and /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 //mcp are 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 AADSTS code, 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
View this README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide