InferPrice

All articles

Building a permission-aware MCP server in Python

2026-10-13 ยท Deep dive AI agents MCP LLMOps

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:

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:

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.

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:

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:

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:

  1. A request with no token returns 401, and the WWW-Authenticate header contains resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource".
  2. Protected resource metadata is served at that URL and lists your authorization server.
  3. A valid token issued for a different audience is rejected with 401.
  4. A tickets:read-only token calling close_ticket gets an isError: true result 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.

Sources