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.
Sources
- MCP specification 2026-07-28: Authorization
- MCP specification 2026-07-28: Tools
- MCP Python SDK docs: Authorization
- MCP Python SDK docs: Tools
- MCP Python SDK source (GitHub, main branch, Oct 13, 2026)
- Atlassian: "Atlassian MCP brings more of your work within reach of any AI agent" (Oct 6, 2026)
Originally published on InferPrice, which tracks GPU cloud and LLM API prices daily.
Top comments (1)
The scope check inside the tool body is the right minimum, and the ToolError-versus-crash distinction is a detail I rarely see written down.
One gap worth a test:
destructive_hint=Falseon close_ticket is a claim the client has to trust, and the MCP spec tells clients to treat annotations from a server as untrusted hints. So the real control is the server-side scope, and a test is what keeps it real. Call every registered tool with a token that holds only tickets:read and assert each non-read-only tool raises ToolError. That catches the tool someone adds in six months and forgets to wrap in require_scope. A cheaper version for CI: iterate the registry and fail if any tool without read_only_hint=True has no require_scope call.I haven't run this, so a question: what does get_access_token() return inside a task the handler spawns? I'd expect the request context to be gone there, which would make a check in a helper thread fail closed or, worse, skip.