Building a permission-aware MCP server in Python
Audience checks, per-tool scopes and tool annotations with the official MCP Python SDK, plus the spec rules that most first implementations get wrong.
Atlassian's rebuilt MCP server, now generally available, enforces three things on every call. It uses OAuth 2.1 with PKCE, so the agent only gets the user's existing permissions. It sorts tools into read, write and destructive, so clients can ask for confirmation. And it applies admin-level scopes and audit logging. This article shows how to build the same three controls into your own MCP server with the official Python SDK, following the current MCP specification (2026-07-28).
What you'll build: a "tickets" MCP server where:
- every request carries a token issued for this server
- each tool checks its own scope
- read, write and destructive tools are labelled so the client can gate them
1. Wire up token verification and audience checks
The MCP server is an OAuth 2.1 resource server. It never logs users in itself. It validates tokens issued by your authorization server (Okta, Entra ID, Keycloak and the like). The spec makes two checks mandatory:
- Audience. Servers MUST validate that access tokens were issued specifically for them as the intended audience (RFC 8707).
- No foreign tokens. Servers MUST NOT accept or transit any other tokens.
from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
RESOURCE = "https://mcp.example.com/mcp" # canonical URI of this server, no trailing slash
class JwtVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
claims = validate_jwt(token) # YOUR code: JWKS signature, exp, iss checks
if claims is None:
return None
return AccessToken(
token=token,
client_id=claims["client_id"],
scopes=claims.get("scope", "").split(),
expires_at=claims["exp"],
resource=claims["aud"], # RFC 8707 resource indicator / audience
subject=claims["sub"],
)
mcp = MCPServer(
"tickets",
token_verifier=JwtVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl(RESOURCE),
required_scopes=["tickets:read"],
validate_token_resource=True,
),
)
Three details matter here.
- Set
validate_token_resource=Trueexplicitly. In the current SDK source, leaving it unset whileresource_server_urlis set logs a warning and behaves asFalse, so tokens minted for other services would be accepted. The SDK notes that 3.0 will makeTruethe default. If your verifier already checks the audience itself, set it toFalseon purpose rather than leaving it unset. audcan be a list. If your IdP issues multi-audience tokens, the SDK's docstring says to setresourceto the entry that equalsresource_server_url.resource_server_urlalso drives discovery. The SDK uses it as the base route for OAuth Protected Resource Metadata (RFC 9728). The spec requires servers to publish this metadata so clients can find your authorization server from theWWW-Authenticateheader on a 401.
2. Enforce scopes per tool, not just at the door
required_scopes is a gate for the whole server. Real systems need finer control: read for search, write for updates, a separate scope for deletes. The SDK exposes the current token inside a tool. Raising ToolError returns an isError: true result the model can read and act on:
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.mcpserver.exceptions import ToolError
def require_scope(scope: str) -> AccessToken:
token = get_access_token()
if token is None or scope not in token.scopes:
raise ToolError(f"This action needs the '{scope}' scope. Re-authorize to grant it.")
return token
Using ToolError (not a generic exception) matters. In the SDK, any other exception is treated as a crash, and the model sees only Error executing tool <name>. That gives the agent nothing to recover from.
At the HTTP level, the spec describes the full step-up flow for insufficient scope:
- respond
403withWWW-Authenticate: Bearer error="insufficient_scope", scope="...", resource_metadata="..." - include all the scopes the operation needs in a single challenge, not one per retry, which would force several re-authorization round-trips for one action
If you front the server with a gateway that can inspect the tool being called, that is the place to issue the challenge. The in-tool check above is the minimum.
3. Label tools by risk with annotations
from mcp.types import ToolAnnotations
@mcp.tool(title="Search tickets",
annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def search_tickets(query: str) -> list[dict]:
require_scope("tickets:read")
...
@mcp.tool(title="Close ticket",
annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False,
idempotent_hint=True))
def close_ticket(ticket_id: str) -> str:
require_scope("tickets:write")
...
@mcp.tool(title="Delete ticket",
annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True))
def delete_ticket(ticket_id: str) -> str:
require_scope("tickets:admin")
...
Annotations let clients show a confirmation prompt before delete_ticket runs, the same read/write/destructive split Atlassian describes. Remember what annotations are:
- Hints, not security. The spec says clients MUST treat tool annotations as untrusted unless they come from trusted servers. The SDK docs say not to rely on clients honouring them. The scope check inside each tool is the real control.
- Only meaningful for writes.
destructive_hintandidempotent_hintonly apply to tools that write, which is why the read-only tool leaves them out.
Spec details that affect cost and latency
Return only the tools a caller can use. The spec allows tools/list to vary by the authorization on the request, for example listing only the tools the caller's granted scopes permit. Every tool definition the client loads is context the model pays for on each turn. Hiding delete_ticket from read-only callers cuts prompt tokens as well as risk.
Keep tool order deterministic. The spec says servers SHOULD return tools in the same order when the set hasn't changed. It notes that this improves LLM prompt-cache hit rates when tools are included in model context. Shuffled tool lists quietly defeat prefix caching.
Never forward the user's token downstream. If close_ticket calls your ticketing API, call it with the server's own credentials, or with a token obtained through a proper exchange. Passing the incoming token through violates the spec's "MUST NOT ... transit any other tokens" rule.
Verify it before you ship
Run these four checks against the server:
- A request with no token returns
401, and theWWW-Authenticateheader containsresource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource". - Protected resource metadata is served at that URL and lists your authorization server.
- A valid token issued for a different audience is rejected with
401. - A
tickets:read-only token callingclose_ticketgets anisError: trueresult with a message naming the missing scope, not a generic crash.
Note: the code follows the official MCP Python SDK documentation and source (main branch, checked October 13, 2026). validate_jwt stands in for your identity provider's JWT validation. Pin your SDK version, because names like MCPServer replaced earlier ones.