Scoped Permissions and Per-Tool RBAC

13 min read Module 4 of 10 Topic 12 of 30

What you'll learn

  • Distinguish authentication (who is calling) from authorization (what they can do)
  • Implement a scope-to-tool mapping enforced on every tools/call request
  • Design role-based tool visibility so tools/list itself reflects caller entitlements
  • Extend authorization beyond whole-tool gating to row/record-level access within a single tool
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Authentication (Module 4, Lessons 10-11) answers “who is this caller?” Authorization answers “what is this caller allowed to do?” A production enterprise MCP server needs both, and needs to apply authorization at two distinct granularities: whether a tool can be called at all, and, within a tool that can be called, which specific records the caller is entitled to see.

Mapping Scopes to Tools

The cleanest implementation attaches a required scope to each tool at registration time, then checks it against request.state.scopes (populated by the auth middleware in Lesson 10) before the tool body executes.

from functools import wraps
from starlette.exceptions import HTTPException

TOOL_SCOPES: dict[str, str] = {
    "kb_query": "kb:read",
    "crm_search_accounts": "crm:read",
    "support_create_ticket": "support:write",
    "crm_create_ticket": "crm:write",
}

def require_scope(tool_name: str):
    """Decorator: enforces the tool's required scope before execution."""
    def decorator(fn):
        @wraps(fn)
        async def wrapper(*args, request=None, **kwargs):
            required = TOOL_SCOPES[tool_name]
            granted = getattr(request.state, "scopes", [])
            if required not in granted:
                raise HTTPException(
                    status_code=403,
                    detail=f"Missing required scope '{required}' for tool '{tool_name}'",
                )
            return await fn(*args, **kwargs)
        return wrapper
    return decorator

@mcp.tool(name="crm_create_ticket")
@require_scope("crm_create_ticket")
async def create_ticket(input: CreateTicketInput) -> dict:
    """Requires crm:write scope, enforced before this body executes."""
    return await crm_client.create_ticket(input)

Filtering tools/list by Entitlement

Beyond rejecting unauthorized calls, a well-designed server should only advertise tools the caller can actually use, so the model never sees, and never wastes a turn attempting, a tool it has no scope for.

@mcp.list_tools()
async def list_tools_for_caller(request) -> list[dict]:
    granted_scopes = set(getattr(request.state, "scopes", []))
    all_tools = mcp.get_registered_tools()
    return [
        tool for tool in all_tools
        if TOOL_SCOPES.get(tool.name) in granted_scopes
    ]
flowchart TD
    Req["Incoming tools/call"] --> Auth{"Valid token/key?\n(authentication)"}
    Auth -->|no| Deny401["401 Unauthorized"]
    Auth -->|yes| Scope{"Token scope includes\nrequired tool scope?\n(authorization: whole-tool)"}
    Scope -->|no| Deny403["403 Forbidden"]
    Scope -->|yes| RowCheck{"Does the tool need\nrecord-level filtering?"}
    RowCheck -->|yes| Filtered["Query scoped to caller's\nentitled records only"]
    RowCheck -->|no| Exec["Tool executes unfiltered"]
    Filtered --> Return["Result returned"]
    Exec --> Return

    style Req fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Auth fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style Scope fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style RowCheck fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style Deny401 fill:#fef2f2,stroke:#dc2626,color:#0F172A
    style Deny403 fill:#fef2f2,stroke:#dc2626,color:#0F172A
    style Filtered fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style Exec fill:#f0fdf9,stroke:#0D9488,color:#0F172A

Beyond Whole-Tool Gating: Resource and Row-Level Authorization

Scope-to-tool mapping answers a binary question: can this identity call this tool at all? Many enterprise scenarios need a finer answer: given that the identity can call the tool, which specific records should the results be restricted to? A sales rep authorized for crm:read should not necessarily see every account in the company, only those in their assigned territory; a support agent authorized for support:read should typically only see tickets for their own team’s product area. This is resource-level (or row-level) authorization, and it cannot be expressed purely as a scope check, it requires the tool implementation itself to apply a filter derived from the caller’s identity.

@mcp.tool(name="crm_search_accounts")
@require_scope("crm_search_accounts")
async def search_accounts(input: SearchAccountsInput, request=None) -> dict:
    """
    Requires crm:read scope (whole-tool gate), AND filters results to the
    caller's assigned territory (row-level gate), unless they hold the
    broader crm:read_all scope reserved for sales operations roles.
    """
    caller_territory = await get_caller_territory(request.state.subject)
    territory_filter = None if "crm:read_all" in request.state.scopes else caller_territory

    results = await crm_client.search(input.query, tier=input.tier, territory=territory_filter)
    return {"count": len(results), "accounts": [r.model_dump() for r in results]}

The distinction matters because it is easy to believe scope-gating alone has solved authorization for a tool, when in fact it has only solved the coarser half of the problem. A code reviewer checking a new tool for authorization correctness should ask both questions explicitly: does this tool check a scope before running at all, and, separately, does this tool’s query correctly narrow results to what the specific caller is entitled to see, rather than everything the underlying system contains.

Role-Based Grouping for Simpler Administration

For organizations managing dozens of tools across many teams, mapping individual scopes to individual tools does not scale administratively. Group scopes into roles (support_agent, sales_agent, read_only_analyst) at the identity provider level, and have the MCP server check role membership via the token’s roles claim, this keeps the server’s authorization logic stable even as new scopes are added, since new tools are simply assigned to an existing role rather than requiring a new scope to be issued to every affected caller.

ROLE_SCOPES: dict[str, set[str]] = {
    "sales_agent": {"crm:read", "crm:write"},
    "support_agent": {"kb:read", "support:read", "support:write"},
    "read_only_analyst": {"crm:read", "kb:read", "warehouse:read"},
}

def scopes_for_roles(roles: list[str]) -> set[str]:
    """Expand a caller's roles (from the token's roles claim) into the
    concrete scopes those roles grant, computed once per request."""
    return set().union(*(ROLE_SCOPES.get(r, set()) for r in roles))

Adding a new tool to the sales_agent role going forward is then a one-line change to ROLE_SCOPES rather than an identity-provider-side change to every sales rep’s individually-issued token, which is what makes this pattern scale as the organization’s tool catalog and headcount both grow independently of each other.

With authentication and authorization complete at both the whole-tool and record level, Module 5 puts this security layer to work wrapping real enterprise systems, REST APIs, databases, and SaaS tools, as MCP capabilities.

Knowledge Check

3 questions to test your understanding

1 An agent authenticates successfully with a valid OAuth token, but that token's scope only includes `kb:read`. The agent calls the `crm_create_ticket` tool, which requires `crm:write`. What should the server do?

2 Why is it better for `tools/list` to return only the tools a given caller is authorized to use, rather than returning the full tool catalog and relying solely on tools/call to reject unauthorized attempts?

3 A sales rep is authorized for the `crm:read` scope, which grants the `crm_get_account` tool. But the rep should only be able to read accounts in their own assigned territory, not every account in the CRM. Is scope-based (whole-tool) authorization sufficient here?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan