---
title: "Per-Role Tool Catalogs for Your MCP Server"
description: "Least privilege for agents is usually all-or-nothing. Front one upstream MCP server with two gateway routes, give each its own tool catalog, and gate them by the IdP group your team already belongs to."
canonicalUrl: "https://zuplo.com/blog/2026/07/16/per-role-mcp-tool-catalogs"
pageType: "blog"
date: "2026-07-16"
authors: "martyn"
tags: "Model Context Protocol, API Security"
image: "https://zuplo.com/og?text=Per-Role%20Tool%20Catalogs%20for%20Your%20MCP%20Server"
---
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](https://github.com/github/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.

<CalloutAudience
  variant="bestFor"
  items={[
    "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](/blog/introducing-zuplo-mcp-gateway?utm_campaign=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](/blog/set-up-virtual-mcp-server-portal?utm_campaign=mcp-gateway)
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.

<CalloutDoc
  title="Curate tools in the portal"
  description="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."
  href="https://zuplo.com/docs/mcp-gateway/how-to/curate-tools?utm_campaign=mcp-gateway"
  icon="book"
/>

## 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`. |

<CalloutTip variant="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.
</CalloutTip>

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:

```ts
// modules/require-role.ts
import { HttpProblems, ZuploRequest, ZuploContext } from "@zuplo/runtime";

interface Options {
  allowedRoles: string[];
}

export default async function (
  request: ZuploRequest,
  context: ZuploContext,
  options: Options,
) {
  // The gateway fills roles from your IdP's group claim during the OAuth step.
  const roles = (request.user?.data as { roles?: string[] })?.roles ?? [];
  if (!options.allowedRoles.some((role) => roles.includes(role))) {
    return HttpProblems.forbidden(request, context, {
      detail: "Your group does not have access to this MCP server.",
    });
  }
  return request;
}
```

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.

<CalloutDoc
  title="Custom inbound policies"
  description="How a custom-code-inbound policy receives the request, the context, and your build-time options, and how to return a Problem Details response."
  href="https://zuplo.com/docs/policies/custom-code-inbound?utm_campaign=mcp-gateway"
  icon="book"
/>

<CalloutTip variant="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.
</CalloutTip>

## 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](/blog/fine-grained-authz-ai-agents?utm_campaign=mcp-gateway)
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](https://portal.zuplo.com/signup?utm_source=zuplo-blog&utm_medium=web&utm_campaign=mcp-gateway)
and split your first upstream into per-role catalogs.