<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sam ros</title>
    <description>The latest articles on DEV Community by sam ros (@sampop3).</description>
    <link>https://dev.to/sampop3</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F742712%2F49a1347d-c918-4ba4-9aaf-bb43b775a890.jpg</url>
      <title>DEV Community: sam ros</title>
      <link>https://dev.to/sampop3</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sampop3"/>
    <language>en</language>
    <item>
      <title>Building a permission-aware MCP server in Python</title>
      <dc:creator>sam ros</dc:creator>
      <pubDate>Tue, 13 Oct 2026 15:59:16 +0000</pubDate>
      <link>https://dev.to/sampop3/building-a-permission-aware-mcp-server-in-python-4ccl</link>
      <guid>https://dev.to/sampop3/building-a-permission-aware-mcp-server-in-python-4ccl</guid>
      <description>&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll build:&lt;/strong&gt; a "tickets" MCP server where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every request carries a token issued &lt;em&gt;for this server&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;each tool checks its own scope&lt;/li&gt;
&lt;li&gt;read, write and destructive tools are labelled so the client can gate them&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Wire up token verification and audience checks
&lt;/h2&gt;

&lt;p&gt;The MCP server is an OAuth 2.1 &lt;strong&gt;resource server&lt;/strong&gt;. 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audience.&lt;/strong&gt; Servers MUST validate that access tokens were issued specifically for them as the intended audience (RFC 8707).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No foreign tokens.&lt;/strong&gt; Servers MUST NOT accept or transit any other tokens.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AnyHttpUrl&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MCPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.provider&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TokenVerifier&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AuthSettings&lt;/span&gt;

&lt;span class="n"&gt;RESOURCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mcp.example.com/mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# canonical URI of this server, no trailing slash
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JwtVerifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TokenVerifier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# YOUR code: JWKS signature, exp, iss checks
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;# RFC 8707 resource indicator / audience
&lt;/span&gt;            &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token_verifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;JwtVerifier&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AuthSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;issuer_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AnyHttpUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;resource_server_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AnyHttpUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RESOURCE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;required_scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickets:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;validate_token_resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;validate_token_resource=True&lt;/code&gt; explicitly.&lt;/strong&gt; In the current SDK source, leaving it unset while &lt;code&gt;resource_server_url&lt;/code&gt; is set logs a warning and behaves as &lt;code&gt;False&lt;/code&gt;, so tokens minted for &lt;em&gt;other&lt;/em&gt; services would be accepted. The SDK notes that 3.0 will make &lt;code&gt;True&lt;/code&gt; the default. If your verifier already checks the audience itself, set it to &lt;code&gt;False&lt;/code&gt; on purpose rather than leaving it unset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aud&lt;/code&gt; can be a list.&lt;/strong&gt; If your IdP issues multi-audience tokens, the SDK's docstring says to set &lt;code&gt;resource&lt;/code&gt; to the entry that equals &lt;code&gt;resource_server_url&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resource_server_url&lt;/code&gt; also drives discovery.&lt;/strong&gt; 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 the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header on a 401.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Enforce scopes per tool, not just at the door
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;required_scopes&lt;/code&gt; 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 &lt;code&gt;ToolError&lt;/code&gt; returns an &lt;code&gt;isError: true&lt;/code&gt; result the model can read and act on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.middleware.auth_context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_access_token&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.mcpserver.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolError&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_access_token&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This action needs the &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; scope. Re-authorize to grant it.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;ToolError&lt;/code&gt; (not a generic exception) matters. In the SDK, any other exception is treated as a crash, and the model sees only &lt;code&gt;Error executing tool &amp;lt;name&amp;gt;&lt;/code&gt;. That gives the agent nothing to recover from.&lt;/p&gt;

&lt;p&gt;At the HTTP level, the spec describes the full step-up flow for insufficient scope:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;respond &lt;code&gt;403&lt;/code&gt; with &lt;code&gt;WWW-Authenticate: Bearer error="insufficient_scope", scope="...", resource_metadata="..."&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;include &lt;strong&gt;all&lt;/strong&gt; the scopes the operation needs in a single challenge, not one per retry, which would force several re-authorization round-trips for one action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Label tools by risk with annotations
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolAnnotations&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Search tickets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_only_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;open_world_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_tickets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;require_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickets:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Close ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_only_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destructive_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="n"&gt;idempotent_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;close_ticket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;require_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickets:write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Delete ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_only_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destructive_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete_ticket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;require_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickets:admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Annotations let clients show a confirmation prompt before &lt;code&gt;delete_ticket&lt;/code&gt; runs, the same read/write/destructive split Atlassian describes. Remember what annotations are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hints, not security.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only meaningful for writes.&lt;/strong&gt; &lt;code&gt;destructive_hint&lt;/code&gt; and &lt;code&gt;idempotent_hint&lt;/code&gt; only apply to tools that write, which is why the read-only tool leaves them out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Spec details that affect cost and latency
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Return only the tools a caller can use.&lt;/strong&gt; The spec allows &lt;code&gt;tools/list&lt;/code&gt; 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 &lt;code&gt;delete_ticket&lt;/code&gt; from read-only callers cuts prompt tokens as well as risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep tool order deterministic.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never forward the user's token downstream.&lt;/strong&gt; If &lt;code&gt;close_ticket&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it before you ship
&lt;/h2&gt;

&lt;p&gt;Run these four checks against the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A request with no token returns &lt;code&gt;401&lt;/code&gt;, and the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header contains &lt;code&gt;resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Protected resource metadata is served at that URL and lists your authorization server.&lt;/li&gt;
&lt;li&gt;A valid token issued for a &lt;strong&gt;different&lt;/strong&gt; audience is rejected with &lt;code&gt;401&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;tickets:read&lt;/code&gt;-only token calling &lt;code&gt;close_ticket&lt;/code&gt; gets an &lt;code&gt;isError: true&lt;/code&gt; result with a message naming the missing scope, not a generic crash.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Note: the code follows the official MCP Python SDK documentation and source (main branch, checked October 13, 2026). &lt;code&gt;validate_jwt&lt;/code&gt; stands in for your identity provider's JWT validation. Pin your SDK version, because names like &lt;code&gt;MCPServer&lt;/code&gt; replaced earlier ones.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/latest/basic/authorization" rel="noopener noreferrer"&gt;MCP specification 2026-07-28: Authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/latest/server/tools" rel="noopener noreferrer"&gt;MCP specification 2026-07-28: Tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://py.sdk.modelcontextprotocol.io/run/authorization/" rel="noopener noreferrer"&gt;MCP Python SDK docs: Authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://py.sdk.modelcontextprotocol.io/servers/tools/" rel="noopener noreferrer"&gt;MCP Python SDK docs: Tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/modelcontextprotocol/python-sdk" rel="noopener noreferrer"&gt;MCP Python SDK source (GitHub, main branch, Oct 13, 2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://atlassian.com/blog/company-news/team26-europe-atlassian-mcp" rel="noopener noreferrer"&gt;Atlassian: "Atlassian MCP brings more of your work within reach of any AI agent" (Oct 6, 2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://inferprice.pages.dev/articles/mcp-server-oauth-scopes-python" rel="noopener noreferrer"&gt;InferPrice&lt;/a&gt;, which tracks GPU cloud and LLM API prices daily.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>mcp</category>
      <category>llmops</category>
    </item>
  </channel>
</rss>
