Multi-Tenant MCP Server
For a high level overview of how MCP servers work with Descope, see the MCP Server docs.
The Python + FastMCP reference implementation is in the Descope AI repo: examples/multi-tenant-mcp.
The Calendar MCP Server example gates tools with SSO and scopes for users who belong to a single tenant.
This example shows how to run one MCP server for many tenants. Each tenant gets a different toolset, but the server itself stays tenant-agnostic: you assign one scope per tool, use Descope access policies to decide which scopes each tenant grants at consent, and let the SDK expose only the tools whose scope is in the token. You don't maintain a tenant-to-tools map in code — onboarding a new tenant is a policy change in Descope, not a redeploy.
How It Works
The access token carries dct (active tenant) and the scopes that tenant's policy granted. The server never hardcodes tenants; it keys off scopes.
- Sign-in: One consent flow, one MCP URL. The user picks a tenant (or gets one automatically if they only belong to one). The token is issued with
dctand that tenant's scopes. - Toolset: Every tool is registered with a scope. The SDK filters
tools/listand rejects calls when the scope is missing. - Tenant data: Tools that read or write tenant-specific data pull the tenant id from
dct. Scopes control what the user can do;dctcontrols where. - Multiple tenants: A user can belong to several tenants but is signed into one at a time. Changing tenants means getting a new token — see Step 5.
Example Flow Diagram
Step 1: Configure the MCP Server
-
Create an MCP Server in Descope:
- Go to MCP Servers and create a server.
- Enable Dynamic Client Registration (DCR) (or CIMD) so clients can register automatically.
- Copy the Well-Known / discovery URL — you'll pass it to the server as
DESCOPE_CONFIG_URL.
-
Enable the
dctclaim in the server's JWT template authorization claims. -
Define one scope per tool (for example
mcp:inventory.read,mcp:metrics.read,mcp:db.write). Use access policies to grant scopes per tenant. A scope the policy doesn't allow won't appear in the token. New tenant = new policy, not new server code.
Step 2: Add Tenant Selection to the Consent Flow
Use one MCP URL and one flow — no per-tenant connection URLs. Configure this in the User Consent Flow on your server's Dynamic Registration Template. It runs during /authorize.
After authentication, add the Tenant Select component. It lists the user's tenants; the selection sets dct on the issued token. The tenant's access policy adds that tenant's scopes in the same pass. Skip the screen when the user has only one tenant.

Note
The screenshot is one possible layout. Swap in your own flow once you've built it.
Step 3: Read the Active Tenant from the Token
Tools that operate on tenant data read dct from the verified access token. Bearer middleware validates the token before your handler runs, so this is just a claim lookup — no extra round trip.
def get_current_tenant() -> tuple[str | None, str | None]:
"""Read the active tenant from the verified token's `dct` claim.
Returns (current_tenant, error_message).
"""
access = get_access_token()
if not access:
return None, "Authentication required: no bearer token on this request."
dct = (access.claims or {}).get("dct")
return (str(dct) if dct else None), Nonefunction getCurrentTenant(token: string): string | null {
const payload = token.split(".")[1];
if (!payload) return null;
const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
return claims.dct != null ? String(claims.dct) : null;
}Step 4: Drive the Toolset from Scopes
Register a single tool catalog. Attach a scope to each tool; the SDK lists and executes only what the token allows.
Both FastMCP and the Express SDK will automatically return a 401 error if the token lacks the required scope.
from fastmcp import FastMCP
from fastmcp.server.auth import require_scopes
mcp = FastMCP("multi-tenant")
@mcp.tool(auth=require_scopes("mcp:inventory.read"))
async def inventory_snapshot() -> str:
"""Inventory for the active tenant."""
tenant, err = get_current_tenant()
if err:
return err
return f"[{tenant}] inventory snapshot OK."
@mcp.tool(auth=require_scopes("mcp:metrics.read"))
async def metrics_ping() -> str:
"""Metrics for the active tenant."""
tenant, err = get_current_tenant()
if err:
return err
return f"[{tenant}] pong."import "dotenv/config";
import express from "express";
import { descopeMcpAuthRouter, defineTool, DescopeMcpProvider } from "@descope/mcp-express";
const app = express();
app.use(express.json());
const provider = new DescopeMcpProvider({
serverUrl: process.env.SERVER_URL,
descopeMcpServerWellKnownUrl: process.env.DESCOPE_MCP_SERVER_WELL_KNOWN_URL,
});
const inventorySnapshot = defineTool({
name: "inventory_snapshot",
description: "Inventory for the active tenant.",
scopes: ["mcp:inventory.read"],
handler: async (extra) => {
const tenant = getCurrentTenant(extra.authInfo.token);
return { content: [{ type: "text", text: `[${tenant}] inventory snapshot OK.` }] };
},
});
const metricsPing = defineTool({
name: "metrics_ping",
description: "Metrics for the active tenant.",
scopes: ["mcp:metrics.read"],
handler: async (extra) => {
const tenant = getCurrentTenant(extra.authInfo.token);
return { content: [{ type: "text", text: `[${tenant}] pong.` }] };
},
});
app.use(
descopeMcpAuthRouter((server) => {
inventorySnapshot(server);
metricsPing(server);
}, provider),
);
app.listen(3000, () => console.log("MCP endpoint: POST http://localhost:3000/mcp"));Scope enforcement vs. tools/list
Enforcement is the scope check at call time. Policies decide which scopes land in the token at consent; the SDK rejects any tool whose scope is missing. That holds even if the client cached an old tools/list.
Visibility is what the client shows in tools/list. Filtering the list to the token's scopes keeps clients like Claude from offering tools the user can't run. FastMCP's AuthMiddleware does this from require_scopes. On Express, the shared StreamableHTTPServerTransport returns one global list — rely on the call-time scope check, and optionally filter the list handler yourself if you want the UI to match.
Because scopes are tenant-specific via policy, filtering by scope gives you per-tenant toolsets without a tenant registry in the server.
Step 5: Users in More Than One Tenant
If a user belongs to one tenant, you're done after Step 4. This section is for users who need to move between tenants.
They're signed into whichever tenant they picked at consent (dct). To work in another tenant they need a fresh token with a new dct and that tenant's scopes. Both values are signed into the token, so the server can't flip the active tenant — the client has to re-authenticate.
With scope-filtered tools/list, the client usually won't call tools it can't use, so switching is something the user starts (reconnect / re-run OAuth), not something a failed tool call triggers. They pick the other tenant in consent and return with an updated token and tool list.
The 401 WWW-Authenticate challenge (RFC 9728) tells clients to run OAuth. That works well for the initial sign-in and for scope step-up. It won't give you silent in-session tenant switching on its own — if the client only sees allowed tools, there's no failed call to prompt a switch.
Optional: switch_tenant tool
A switch_tenant tool gives agents and users a named way to ask for another tenant and see what's available. It doesn't replace the token — responses come back over 200 — but it can list choices and tell the user to reconnect. Include the tenants claim in the JWT template to read membership from the token:
@mcp.tool
async def switch_tenant(tenant_id: str) -> str:
"""Name a target tenant and explain that switching requires re-auth."""
access = get_access_token()
claims = access.claims or {}
current = claims.get("dct")
available = list((claims.get("tenants") or {}).keys())
if tenant_id == current:
return f"Already signed into {tenant_id!r}."
if tenant_id not in available:
return f"{tenant_id!r} is not one of your tenants: {available}."
return (
f"To switch from {current!r} to {tenant_id!r}, reconnect and select {tenant_id!r} "
f"in the consent flow. Your client will re-run sign-in and come back with the new tenant."
)import { z } from "zod";
function getTokenClaims(token: string): Record<string, any> {
const payload = token.split(".")[1];
if (!payload) return {};
return JSON.parse(Buffer.from(payload, "base64url").toString());
}
const switchTenant = defineTool({
name: "switch_tenant",
description: "Name a target tenant and explain that switching requires re-auth.",
input: { tenantId: z.string().describe("Target tenant id") },
handler: async (args, extra) => {
const claims = getTokenClaims(extra.authInfo.token);
const current = claims.dct;
const available = Object.keys(claims.tenants ?? {});
if (args.tenantId === current) {
return { content: [{ type: "text", text: `Already signed into '${args.tenantId}'.` }] };
}
if (!available.includes(args.tenantId)) {
return {
content: [{
type: "text",
text: `'${args.tenantId}' is not one of your tenants: ${JSON.stringify(available)}`,
}],
};
}
return {
content: [{
type: "text",
text: `To switch from '${current}' to '${args.tenantId}', reconnect and select '${args.tenantId}' in the consent flow. Your client will re-run sign-in and come back with the new tenant.`,
}],
};
},
});