An MCP server is either connected and the agent gets every tool on it, or it isn’t and the agent gets nothing. An engineer’s coding agent and a support rep’s chat assistant point at the same GitHub MCP server and see the same catalog, including the tools that delete files and merge pull requests. Nobody decided that; it’s what “connect the server” means by default.
The decision you actually want is per-role: support sees the read tools, engineering sees read plus write, and the same login sorts people into the right catalog. No second MCP server, no forked deployment, just one upstream, two gateway routes, and the group your identity provider already puts people in.
- Platform and security teams handing MCP servers to more than one role
- CISOs who want least privilege for agents mapped to existing IdP groups
- Anyone whose agents see write tools they should never be able to call
Two virtual servers, one upstream
A virtual server in the Zuplo MCP Gateway is a route that fronts an upstream MCP server, puts it behind your auth, and applies a tool policy. Nothing stops two virtual servers from fronting the same upstream, and that is the whole trick.
Run the MCP Gateway Virtual Server wizard twice from your project’s Code
tab. Both times pick GitHub as the upstream; only the path and the tools you expose
differ. Name the first route /mcp/github-readonly and the second
/mcp/github-write. That gives two independent control surfaces over one GitHub
backend, each running the same request flow:
| Step | What the route does |
|---|---|
| Authenticate | Logs the user in through your IdP |
| Token exchange | Swaps the token for one GitHub accepts |
| Gate | Allows or rejects the request based on the user’s group |
| Curate | Trims the catalog to that route’s tools |
The wizard sets up everything but the gate, which is the rest of this post. Because both routes front the same upstream, point them at the same GitHub token-exchange policy so each user authorizes GitHub once, not once per route. The portal walkthrough covers the wizard end to end.
Curate the read and write catalogs
The capability filter is an allowlist. Anything you don’t tick is hidden, and a direct call to a hidden tool comes back as a JSON-RPC not-found error rather than reaching the upstream.
At the wizard’s Tools step, choose Curate. The gateway groups the
upstream’s tools by what they do; tick what each route should expose. On
/mcp/github-readonly tick the read tools, get_file_contents,
list_commits, search_code, and pull_request_read, and leave everything else
off. On /mcp/github-write tick those plus the writes the role needs, like
create_pull_request and merge_pull_request.
The destructive tools you never want an agent to reach stay unticked on both, so neither route can call them. And because Curate is an allowlist, a tool GitHub adds upstream later stays hidden until you tick it; the catalog never grows silently.
Curate tools in the portal
The wizard's Curate step in full: how the gateway groups an upstream's tools and how to pick exactly which ones each virtual server exposes.
Gate each route by IdP group
Two catalogs are only half the control: without a gate, anyone who knows the write path can connect to it. The gate checks the authenticated user’s group and rejects anyone who lacks it.
Most of that work is already done. When the inbound OAuth policy authenticates a
user through your IdP, the gateway reads their group
or role claim off the IdP token and lands it on request.user.data.roles. It
recognizes the usual claim names: groups, roles, role, permissions, and
https://zuplo.com/roles. What you do on the IdP side depends on which one you
run:
| Your IdP | What to configure |
|---|---|
| Microsoft Entra | App roles arrive as roles once assigned, nothing extra. For group membership, add the optional groups claim and configure it to emit group names, since the default is opaque object IDs. |
| Okta | Add a groups claim to the OIDC app. Nothing extra. |
| Auth0 | Add a short post-login Action that puts the claim under https://zuplo.com/roles. |
Common mistake:
Don’t gate with a scope check here. The gateway issues its own token to the MCP
client with a fixed mcp:tools scope, so your IdP’s scopes never reach the
route. Roles survive, scopes don’t, so gate on roles.
The gate is the one piece the wizard can’t generate: it’s logic, not configuration. A few lines of custom policy that run after authentication:
Save that as a module in your project, then add a Custom Code inbound policy to each route and point it at the module. Order matters: list it after the OAuth policy, so the roles are populated by the time the gate runs, and before the capability filter.
Set its allowedRoles option per route: Engineering on the write route,
Support plus Engineering on the read route so engineers see the read catalog
too. The names are the groups you already keep in your IdP, the only setup left:
emit the claim under a name the gateway recognizes, and the roles flow through on
their own.
Custom inbound policies
How a custom-code-inbound policy receives the request, the context, and your build-time options, and how to return a Problem Details response.
Common mistake:
Gate on the group of the human the agent acts for, not on the agent’s own service identity. If you key the gate to the agent, every user the agent serves inherits the union of everyone’s access, which is the all-or-nothing problem you started with. The verified user identity from the inbound OAuth flow is the thing to check.
Same login, two tool catalogs
You hand each group the URL for its route, and the gate backstops the rest. Everyone logs in through the same SSO screen; the group claim decides what they get:
| Support rep | Engineer | |
|---|---|---|
| IdP group | Support |
Engineering |
| Route | /mcp/github-readonly |
/mcp/github-write |
| Catalog listed | Read tools | Read plus write tools |
The write tools aren’t hidden behind a permission prompt for the support rep,
they’re absent: the read route never lists them, and if that rep points a client
at /mcp/github-write, the gate returns a 403 before any tools load. The read
route allows both groups, so engineers can use either.
The agent can’t call a tool it can’t see, and a user can’t reach a route their group doesn’t open. The blast radius of a confused or compromised agent is capped at whatever its operator’s role could already do, which is the per-call containment argument applied at the route boundary instead of inside every request.
Your IdP groups become the authorization source
This is the cheap option because you already maintain the inputs. Engineering and Support are groups in your IdP, kept current by the SCIM provisioning that already syncs your directory as people join teams and leave. You are not building a new authorization system for agents, you are pointing the agents’ tool access at the one you already audit.
When someone moves from support to engineering, the group change that already updates their other access updates which MCP tools their agents can call, with no gateway edit. When they leave, deprovisioning closes both routes at once. That is the property a CISO is actually buying: agent tool access governed by the same source of truth as everything else, not a parallel allowlist that drifts.
The MCP Gateway is in public beta on every plan, the free tier included. Spin up a free Zuplo project and split your first upstream into per-role catalogs.