DEV Community

Cover image for Your MCP server probably accepts tokens that were never meant for it
sam ros
sam ros

Posted on Edited on Originally published at inferprice.pages.dev

Your MCP server probably accepts tokens that were never meant for it

Your MCP server probably accepts tokens that were never meant for it. In the official Python SDK, that's what happens when you leave one setting unset. And the tool someone adds six months from now will probably skip your scope check, unless a test stops it.

This matters now because Atlassian's rebuilt MCP server is generally available, and it shows the pattern that's becoming the norm. It uses OAuth 2.1 with PKCE so the agent only gets the user's own permissions, it sorts tools into read, write and destructive, and it adds admin scopes and audit logging. You can build the same controls into your own server with the official Python SDK and the current MCP specification (2026-07-28). The example is a small "tickets" server, and the code below runs in CI against mcp 2.3.0, the current release.

Reject tokens issued for someone else

Your MCP server is an OAuth 2.1 resource server. It never logs anyone in. It validates tokens issued by your authorization server (Okta, Entra ID, Keycloak and the like). The spec makes two checks mandatory: servers MUST validate that a token was issued for them as the audience (RFC 8707), and MUST NOT accept or pass on 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,
    ),
)
Enter fullscreen mode Exit fullscreen mode

Set validate_token_resource=True explicitly. In mcp 2.3.0, leaving it unset while resource_server_url is set logs a warning and behaves as False, so tokens minted for other services get accepted. The SDK says 3.0 will flip the default. If your verifier already checks the audience, set it to False on purpose rather than leaving it unset. If your IdP issues multi-audience tokens, the SDK's docstring says to set resource to the entry that equals resource_server_url.

resource_server_url also drives discovery. The SDK uses it as the base route for OAuth Protected Resource Metadata (RFC 9728), which the spec requires so clients can find your authorization server from the WWW-Authenticate header on a 401.

Check scopes inside every tool

required_scopes gates the whole server. Real systems need more: read for search, write for updates, a separate scope for deletes. The SDK exposes the current token inside a tool:

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:  # no token fails closed
        raise ToolError(f"This action needs the '{scope}' scope. Re-authorize to grant it.")
    return token
Enter fullscreen mode Exit fullscreen mode

Raise ToolError, not a generic exception. The SDK turns ToolError into an isError: true result the model can read and act on. Anything else is treated as a crash, and the model sees only Error executing tool <name>, which gives the agent nothing to recover from.

Keep the token is None branch. Writing the check as if token and scope not in token.scopes reads almost the same, but it lets every call without a token straight through.

At the HTTP level, the spec describes the full step-up flow: respond 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="...", resource_metadata="...", and list all the scopes the operation needs in one challenge, not one per retry. If a gateway in front of the server can see which tool is being called, issue the challenge there. The in-tool check is the minimum.

Annotations are hints, not security

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")
    ...
Enter fullscreen mode Exit fullscreen mode

Annotations let a client ask for confirmation before delete_ticket runs, the same read/write/destructive split Atlassian describes. But destructive_hint=False on close_ticket is a claim the client has to take on trust, and the spec tells clients to treat annotations from untrusted servers as untrusted. The SDK docs say not to rely on clients honouring them. Your scope check is the control. destructive_hint and idempotent_hint only mean something on tools that write, which is why the read-only tool leaves them out.

Hints aren't purely decorative, which is exactly why they can't be trusted. VS Code skips its confirmation prompt for tools marked readOnlyHint and asks for every other tool, so a write tool mislabelled as read-only runs with no prompt at all. Most clients do far less: in March the MCP project's own blog noted that no client surfaces annotations in its approval prompts. The strongest version of this rule isn't a hint. If a deployment doesn't need write tools, don't register them, and turn them on only through server config. A tool that doesn't exist can't be called.

Make the scope check impossible to forget

A scope check that lives in each tool body is only as good as the next person who adds a tool. So test the rule, not the tools you have today: call every registered tool with a token that holds only tickets:read, and assert that each tool not marked read-only raises ToolError.

import asyncio
import pytest
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.mcpserver.exceptions import ToolError

ALL_SCOPES = ["tickets:read", "tickets:write", "tickets:admin"]

async def as_caller(scopes, fn, *args):
    user = AuthenticatedUser(AccessToken(token="t", client_id="test", scopes=scopes))
    reset = auth_context_var.set(user)  # what the auth middleware does per request
    try:
        return await fn(*args)
    finally:
        auth_context_var.reset(reset)

def test_every_write_tool_rejects_a_read_only_token():
    # list as an admin: a scope-filtered tools/list returns nothing to a token-less caller
    tools = asyncio.run(as_caller(ALL_SCOPES, mcp.list_tools))
    write_tools = [t for t in tools if not (t.annotations and t.annotations.read_only_hint)]
    assert write_tools, "no write tools listed, so this test would pass on nothing"
    for tool in write_tools:
        args = {p: "T-1" for p in tool.input_schema.get("required", [])}
        with pytest.raises(ToolError, match="scope"):
            asyncio.run(as_caller(["tickets:read"], mcp.call_tool, tool.name, args))
Enter fullscreen mode Exit fullscreen mode

When someone adds reopen_ticket next year and forgets require_scope, this test goes red before the tool ships. Two lines guard the test itself. The first version enumerated mcp.list_tools() with no token in the context, so if you later filter tools/list by scope (see below), the loop gets an empty list and passes while a tickets:read token can still delete. Listing as an admin and asserting that write tools exist closes that hole; the example repo has mutation tests that prove both cases. You could instead scan the registry for tools without require_scope in their source, which is cheaper. But that only proves the call appears somewhere in the function, not that it runs before the side effect. The behavioral test is the one to keep.

Background work keeps a stale token

get_access_token() reads a context variable that the SDK's auth middleware sets for each request and resets when the request ends. What your code sees depends on how it starts extra work:

Started with Sees the token?
A sync tool (def), which the SDK runs via anyio.to_thread.run_sync Yes, AnyIO copies the context to the worker thread
asyncio.create_task() Yes, and it keeps seeing it after the request ends
asyncio.to_thread() Yes
loop.run_in_executor() No, None
threading.Thread No, None

The None cases are the safe ones: require_scope fails closed and denies. The dangerous case is the task that outlives its request. It authorizes with a snapshot of the token, even after that token has expired or been revoked. So check scopes in the handler before you spawn anything, pass the values the background work needs explicitly, and never call require_scope from a task that can outlive the request.

Spec details that cost you tokens

The spec lets tools/list vary by the authorization on the request, so you can list only the tools the caller's scopes allow. Be careful what you hide. The spec has clients start with the minimal scopes_supported set and add scopes through step-up when a call fails with insufficient_scope. A tool the model never sees is never called, so the challenge never fires and a user who would happily grant tickets:write is never asked. Hide only the tools behind scopes this caller can't get at all, such as delete_ticket for users who will never hold tickets:admin. That still saves prompt tokens, since every tool definition the client loads costs context on every turn, and it doesn't break the upgrade path for the rest.

Keep tool order stable. The spec says servers SHOULD return tools in the same order when the set hasn't changed, because it improves prompt-cache hit rates when tools sit in the model's context. A shuffled list quietly defeats prefix caching.

Never forward the user's token downstream. If close_ticket calls your ticketing API, use the server's own credentials or a token from a proper exchange. Passing the incoming token through breaks the spec's "MUST NOT ... transit any other tokens" rule.

Do this today

Add the read-only-token test above to your server's CI. It takes a few minutes, it catches the one mistake annotations can't, and it keeps protecting you as the tool list grows. Then check your server for the four basics: no token gets a 401 with resource_metadata in WWW-Authenticate; the protected resource metadata is served at that URL; a token for a different audience gets a 401; and a tickets:read token calling close_ticket gets an isError: true result that names the missing scope.

Thanks to Arhan Canli, whose comment prompted the scope test and the background-task section, and to Edward Izgorodin and Moein, whose comments corrected the advice on hiding tools and hardened the test. Updated October 15, 2026. The code is tested in CI against mcp 2.3.0 on Python 3.13 (articles/examples/mcp-scopes in this site's repo). validate_jwt stands in for your identity provider's JWT validation. Pin your SDK version, because names like MCPServer replaced earlier ones.

Sources


Originally published at inferprice.pages.dev.

Top comments (9)

Collapse
 
izgorodin profile image
Edward Izgorodin •

Listing only the tools a token's scopes allow and challenging for a missing scope pull against each other. Step-up starts from a call that fails: the spec has the server answer insufficient_scope with the scopes the operation needs, and it describes scopes_supported as the minimal set, with more requested incrementally through that flow. If tools/list hides close_ticket and delete_ticket from a tickets:read token, the model never calls them, the challenge is never issued, and a user who would grant tickets:write is never asked. Hiding fits scopes a caller cannot get at all. A tool behind a scope the user can grant has to stay listed for the challenge, or the ToolError that names the scope, to reach anyone.

The filter can also empty the CI test. In mcp 2.3.0 the tools/list handler returns MCPServer.list_tools(), and the test enumerates through that same method outside call_as, with no token in the context. On a copy of the tickets server with require_scope removed from delete_ticket, the test fails on delete_ticket, as it should. Add a list_tools override that keeps only the tools whose scope the token holds, and the same test passes: the loop gets an empty list, while a tickets:read token calling delete_ticket directly still deletes. Written as a middleware, the filter would not reach that method. Written as an override, it does. Asserting that the loop called at least one tool, or enumerating the registry rather than the listing, keeps the test from passing on nothing.

Collapse
 
sampop3 profile image
sam ros •

You're right on both counts, and thanks for testing it. I reproduced the empty-loop case in CI: with a list_tools override that filters by the token's scopes, the old test passed while a tickets:read token could still delete. The test now lists tools with an all-scopes token, asserts the write tools it found are exactly close_ticket and delete_ticket, and a separate test builds your filtered server with the scope check removed and confirms the rule still catches it. I also rewrote the hiding advice: tools behind a scope the user can grant stay listed so the insufficient_scope challenge can reach them, and only tools behind scopes the caller can never get are hidden. The post is updated and credits you.

Collapse
 
izgorodin profile image
Edward Izgorodin •

Listing as an all-scopes caller fixes the empty loop, but it hands the test's coverage to ALL_SCOPES, a list someone has to keep current. A scope filter needs each tool's scope declared somewhere outside the tool body, because require_scope only runs inside it. Add export_tickets next year behind a new tickets:export scope, give it its entry in that map, and forget require_scope: the filter hides it from a token holding the three scopes in ALL_SCOPES, the loop checks close_ticket and delete_ticket and passes, and the assertion that the write tools are exactly those two passes with it. A tickets:read token calling export_tickets by name still runs it, since in mcp 2.3.0 call_tool never consults the listing.

On a copy of the tickets server with that filter written as a list_tools override and the test as the post now has it, under mcp 2.3.0, the test passes and the direct call with tickets:read goes through. Enumerating the registry instead, MCPServer.list_tools(mcp) past the override, the same loop fails on export_tickets because no ToolError is raised, and passes again once require_scope is back. The registry needs no scope list, so the test keeps testing the rule and not the scopes that existed when it was written.

Thread Thread
 
sampop3 profile image
sam ros •

You’re right, Edward. Listing as an all-scopes caller only moved the blind spot: the test now covers the scopes ALL_SCOPES held when it was written, so a tool behind a scope added later can be hidden by the filter and skipped, while call_tool still runs it because it never looks at the listing. Enumerating the registry directly, MCPServer.list_tools(mcp) past the override, needs no scope list, so it keeps testing the rule instead of a snapshot of the scopes. That’s the version I’d keep. Thanks for running it against a real server again.

Collapse
 
arhancanli profile image
Arhan Canli •

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=False on 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.

Collapse
 
sampop3 profile image
sam ros •

Thanks Arhan, both points are good.
On annotations: agreed. The behavioral test is the one I'd keep: call every registered tool with a tickets:read-only token and assert that each tool without read_only_hint=True raises ToolError. The registry check is cheaper, but it only proves require_scope appears in the function, not that it runs before the side effect. I'll add the test to the post.
On get_access_token() in spawned work: it reads a ContextVar that AuthContextMiddleware sets for the request and resets in a finally block. So the answer depends on how you spawn:
• asyncio.create_task() copies the context when the task is created. The task sees the token, and keeps seeing it after the request ends and the middleware resets the variable.
• asyncio.to_thread() propagates the context too.
• loop.run_in_executor() and a plain threading.Thread don't, so get_access_token() returns None there.
I checked this with a short script that mirrors the middleware's set and reset (Python 3.13).
So in a helper thread, require_scope as written fails closed, because a None token raises ToolError. It only skips if someone writes the check as if token and scope not in token.scopes, which is worth a lint rule. The case I'd worry about more is the opposite one: a background task that outlives the request keeps authorizing with a snapshot of the token, even after it expires or is revoked. My rule: check scopes in the handler before spawning anything, pass the values you need into the background work explicitly, and never call require_scope from a task that can outlive the request.

Collapse
 
onlymoein profile image
Moein •

The per-tool scope bit is underrated. I default to read-only and make write tools not exist unless enabled in config. Have you found clients actually respect readOnlyHint, or is it mostly decorative right now?

Collapse
 
sampop3 profile image
sam ros •

Partly, and in the direction that makes it riskier. VS Code skips its confirmation prompt for tools marked readOnlyHint and asks for everything else, so a write tool labelled read-only runs with no prompt: code.visualstudio.com/docs/copilot.... Beyond that, the MCP project's blog noted in March that no client surfaced annotations in approval prompts: blog.modelcontextprotocol.io/posts.... So I treat the hint as something to get right, never something to rely on. Your pattern is the stronger control, and I've added it to the post: if a deployment doesn't need write tools, don't register them.

Collapse
 
tgapps profile image
Tg Apps •

The behavioral scope test is a stronger guard than scanning for require_scope. There is still an interesting failure mode if a new mutating tool is accidentally marked read_only_hint=True: the current write_tools filter would exclude it from that test. An explicit authorization matrix, plus a denied-call assertion that the service had no side effects, could give a second independent check. Would you keep that matrix alongside tool registration so changes to annotations cannot silently reduce test coverage?