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.