Guide · Authentication
MCP server RBAC
Authentication establishes who is calling your MCP server. RBAC — role-based access control — determines what they can call. In an MCP server, RBAC enforcement belongs in the tool handler, not in the HTTP middleware. The middleware enforces authentication (is this a valid, known caller?); RBAC enforces authorization (does this caller have permission to invoke this specific tool?). These are distinct concerns with distinct failure modes: an auth failure returns HTTP 401 before the MCP session starts; an authorization failure returns a tool-level error (isError: true) after the session is established, leaving the session alive for tools the caller is permitted to use. This guide covers the RBAC model, per-tenant isolation, scope-based enforcement, and the audit log pattern.
TL;DR
Define a permission per tool (e.g. tools:write, data:read). In each tool handler, check context.identity.scopes.includes(requiredPermission) — if not, return { isError: true, content: [{ type: 'text', text: 'Permission denied: ...' }] }. Never throw an uncaught exception for authorization failures. For multi-tenant servers, also verify context.identity.tenantId === resource.tenantId before returning any data. Log every denied call with caller sub and tool name to your structured log.
The RBAC model for MCP
The standard RBAC model maps subjects (users, service accounts) → roles → permissions. In an MCP server context:
| RBAC concept | MCP equivalent | Where it lives |
|---|---|---|
| Subject | payload.sub from JWT, or API key ID | Verified in HTTP middleware; stored in session identity |
| Role | payload.roles custom claim (e.g. ["admin", "reader"]) | Extracted from JWT in middleware; stored in session identity |
| Permission/Scope | OAuth 2.0 scope claim (e.g. "tools:write data:read") | Extracted from JWT in middleware; stored in session identity |
| Resource | A specific tool or tool parameter domain | Checked in tool handler |
| Access decision | Has subject the required scope for this tool? | In tool handler, before business logic runs |
You can use either scopes (OAuth 2.0 scope claim) or roles (custom claim) for permission checks — or both, using scopes for coarse-grained API-level access and roles for fine-grained tool-level access. The key principle: define the required permission for each tool at registration time (in a TOOL_PERMISSIONS map), not scattered across individual handler implementations.
Centralised permission map and enforcement wrapper
// permissions.ts — single source of truth for tool permissions
export const TOOL_PERMISSIONS: Record<string, string[]> = {
'list_resources': ['data:read'],
'read_resource': ['data:read'],
'create_resource': ['data:write'],
'update_resource': ['data:write'],
'delete_resource': ['data:write', 'data:delete'], // requires both
'admin_list_all_users': ['admin'],
'export_data': ['data:read', 'data:export'],
'send_notification': ['notifications:write'],
};
// Enforce RBAC and return a tool error (not an exception) on failure
export function requireScopes(
toolName: string,
identity: McpIdentity,
logger: pino.Logger
): { denied: true; result: McpToolResult } | { denied: false } {
const required = TOOL_PERMISSIONS[toolName] ?? [];
const missing = required.filter(scope => !identity.scopes.includes(scope));
if (missing.length > 0) {
logger.warn({
tool: toolName,
sub: identity.sub,
tenant_id: identity.tenantId,
required_scopes: required,
missing_scopes: missing,
}, 'RBAC denial');
return {
denied: true,
result: {
isError: true,
content: [{
type: 'text',
text: `Permission denied: tool '${toolName}' requires scope(s): ${missing.join(', ')}`,
}],
},
};
}
return { denied: false };
}
// Usage in a tool handler
server.tool('delete_resource', async (args, context) => {
const check = requireScopes('delete_resource', context.identity, logger);
if (check.denied) return check.result;
// Business logic runs only if permission check passed
await deleteResource(args.id, context.identity.tenantId);
return { content: [{ type: 'text', text: 'Deleted.' }] };
});
The centralised TOOL_PERMISSIONS map means that when you add a new tool, you add its required permissions in one place and the enforcement wrapper applies them consistently. There is no risk of forgetting to add a permission check in a specific handler — the wrapper is called at the top of every handler.
Per-tenant isolation in multi-tenant MCP servers
Multi-tenant MCP servers host data for multiple tenants in the same database. RBAC at the scope level controls what operations a caller can perform — but per-tenant isolation controls whose data they can access. Both checks are required; scope check alone is insufficient for multi-tenancy.
// Tenant-aware resource fetch — enforce in the data layer, not just the handler
async function getResourceForTenant(
resourceId: string,
tenantId: string
): Promise<Resource | null> {
// SQL query includes tenant_id in the WHERE clause — cannot leak cross-tenant
const row = db.prepare(
'SELECT * FROM resources WHERE id = ? AND tenant_id = ?'
).get(resourceId, tenantId);
return row ?? null;
}
server.tool('read_resource', async (args, context) => {
// 1. RBAC check — does this caller have data:read scope?
const scopeCheck = requireScopes('read_resource', context.identity, logger);
if (scopeCheck.denied) return scopeCheck.result;
// 2. Tenant isolation — fetch scoped to this caller's tenant_id
const resource = await getResourceForTenant(args.id, context.identity.tenantId!);
if (!resource) {
// Return not-found — not a permission error, to avoid leaking tenant membership
return {
isError: true,
content: [{ type: 'text', text: `Resource '${args.id}' not found` }],
};
}
return { content: [{ type: 'text', text: JSON.stringify(resource) }] };
});
The tenant isolation check must happen in the data access layer, not just in the handler. If you enforce it only in handlers, a future refactor that adds a new code path to the same table might bypass the check. Make tenant filtering part of every database query so it is structurally impossible to return cross-tenant data — the query itself is the enforcement.
Return a generic "not found" for cross-tenant resource requests rather than "access denied". A "not found" response does not reveal whether the resource exists for a different tenant — an "access denied" response confirms that it does. See MCP server security monitoring for alerting on patterns that suggest cross-tenant enumeration attempts.
Scope inheritance and role hierarchies
For servers where admin users should have all permissions, and writer users should have all reader permissions, implement scope expansion at identity extraction time rather than in every handler:
const ROLE_SCOPE_EXPANSION: Record<string, string[]> = {
'admin': ['data:read', 'data:write', 'data:delete', 'data:export', 'notifications:write', 'admin'],
'writer': ['data:read', 'data:write'],
'reader': ['data:read'],
};
function expandScopes(scopes: string[], roles: string[]): string[] {
const expanded = new Set(scopes);
for (const role of roles) {
const roleScopes = ROLE_SCOPE_EXPANSION[role] ?? [];
roleScopes.forEach(s => expanded.add(s));
}
return Array.from(expanded);
}
// In the JWT validation middleware, expand scopes at identity extraction
const identity: McpIdentity = {
sub: payload.sub!,
scopes: expandScopes(
(payload['scope'] as string)?.split(' ') ?? [],
(payload['https://alivemcp.com/roles'] as string[]) ?? []
),
tenantId: payload['https://alivemcp.com/tenant_id'] as string | undefined,
};
By expanding at identity extraction, the requireScopes enforcement wrapper always works on a fully-resolved scope list. You do not need to add role checks alongside scope checks in every handler — the expansion step is the only place where role-to-scope mapping is applied.
RBAC audit logging
Every authorization denial must be logged with enough context to reconstruct the event: who was denied, what they tried to call, what permissions they had, and what permissions were required. Use structured logging so the audit trail is queryable:
// Log all denials at WARN level — not DEBUG (must always be captured)
logger.warn({
event: 'rbac_denial',
tool: toolName,
sub: identity.sub,
tenant_id: identity.tenantId,
caller_scopes: identity.scopes,
required_scopes: required,
missing_scopes: missing,
session_id: context.sessionId,
}, 'RBAC denial');
// Also log grants at DEBUG level for full audit trail (production: off by default)
logger.debug({
event: 'rbac_grant',
tool: toolName,
sub: identity.sub,
tenant_id: identity.tenantId,
session_id: context.sessionId,
}, 'RBAC grant');
Set up a log aggregation alert (in Loki or Elasticsearch) on high rbac_denial rates from a single sub — this pattern indicates either a misconfigured client (the usual case) or a credential compromise where an attacker is probing which tools they can access.
AliveMCP probe access and RBAC
AliveMCP's uptime probes need to call at least one tool per probe cycle to verify end-to-end health. For RBAC-enforced MCP servers, either:
- Create a dedicated probe API key or service account with a minimal scope (e.g.
health:ping) and register a lightweighthealth_pingtool that requires only that scope - Grant the probe account
data:readscope and monitor a read-only tool — this tests the actual tool path, not a synthetic health endpoint
The probe should never hold data:write or admin scopes — a compromised probe API key should not be able to modify or delete data. Grant the minimum scope needed for health checking. AliveMCP logs the response from every probe so you can see whether the tool returned an RBAC denial (a misconfiguration in the probe's identity) or a legitimate tool result.
Related questions
Should RBAC checks go in the tool handler or in a server-level middleware?
In the tool handler, via a shared enforcement wrapper. A server-level middleware can enforce coarse-grained access (is this caller allowed to use the MCP server at all?), but tool-level RBAC needs to know the tool name and required permissions, which are only available inside the handler. The TOOL_PERMISSIONS map + requireScopes wrapper pattern gives you the consistency of middleware (single enforcement point) with the access to per-tool context that middleware lacks.
What error type should an RBAC denial return?
A tool-level error: { isError: true, content: [{ type: 'text', text: '...' }] }. Do not throw an unhandled exception — that terminates the MCP handler loop and may crash the session. Do not return HTTP 403 — the request is already inside the MCP session; HTTP-level status codes are not meaningful at tool-call time. The MCP protocol communicates errors via the tool result, not via HTTP status codes mid-session.
How do I test RBAC in integration tests?
Issue test JWTs with specific scopes from your test auth server (or a test-only jose SignJWT helper). For each tool, write one test with the required scope (expect success) and one without (expect isError: true with the denial message). Test the multi-tenant isolation with two test tenants — assert that tenant A's credentials cannot read tenant B's resources. These tests must cover the denial paths explicitly — happy-path-only testing leaves RBAC failures undiscovered until production.
Can I skip RBAC for internal tools that only admin accounts will call?
No. "Only admin accounts will call this" is an assumption that breaks when credentials are shared, a misconfigured client uses the wrong account, or a new developer doesn't know the convention. Enforce RBAC on every tool, including admin tools — the requireScopes('admin_tool', identity, logger) call takes one line and provides an audit trail. The cost of skipping it is a silent authorization bypass that only surfaces during an incident investigation.
Further reading
- MCP server JWT validation — extracting claims for RBAC
- MCP server authentication — session-bound identity
- MCP server multi-tenancy — data isolation patterns
- MCP server structured logging — RBAC audit trail
- MCP server log aggregation — querying denial patterns
- MCP server security monitoring — alerting on RBAC denial spikes
- AliveMCP — uptime monitoring with minimal-scope probe access