An email allowlist answers one question: is this caller allowed in at all? Real servers need a second answer: which of my tools may this particular caller use? A reader should be able to list notes but never delete one; an admin should be able to delete anyone’s. That is authorization, and FastMCP gives you two levers for it: a declarative require_scopes(...) on each tool, and the caller’s identity inside a handler for checks a static scope cannot express.

This tutorial builds a notes server with three roles (reader, editor, admin) mapped to scopes, gates each tool by scope, and adds one ownership rule an editor cannot escape. Authorization needs a real bearer token, so the server runs over HTTP; a StaticTokenVerifier stands in for an OAuth provider so the whole thing runs offline and deterministically. The stack is Mac-native: uv, make, and pytest.

Two layers of authorization

  • Coarse-grained, declarative. @mcp.tool(auth=require_scopes("notes:write")) gates a tool by scope. FastMCP does more than block the call: it hides the tool from any caller who lacks the scope. The tool is absent from that caller’s tools/list and a direct call comes back as “Unknown tool”, so its existence is never leaked to a client that may not use it.
  • Fine-grained, imperative. Some rules depend on the data, not just the caller’s role: “an editor may delete only their own notes.” A scope cannot say that. Inside the handler you read the caller’s identity from the verified token and raise a ToolError with a clear message when the rule is violated.

Both sit on top of authentication: the token itself must be valid, or the transport rejects the request with 401 before any tool runs.

Roles are bundles of scopes

Keep per-tool checks about capabilities (notes:write), not job titles (editor). A role is just a named set of scopes. Identities map to roles, roles expand to scopes, and tools require scopes. Adding a role never touches a tool.

What you will build

  • auth.py: the identity directory, the role→scope map, and the token verifier.
  • server.py: four tools gated by scope, one with an extra ownership check.
  • serving.py: a helper that runs the server over HTTP in a background thread.
  • client.py: a demo that connects as each role.
  • A pytest suite proving visibility, calls, identity, ownership, and auth.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Familiarity with FastMCP and the Client (see Build an MCP Server with FastMCP). Real token verification is covered in Add GitHub OAuth to a FastMCP Server and Secure a FastMCP Server with Clerk; this article uses a static verifier so it stays offline.

Step 1: Add project hygiene

Create the file

mkdir -p per-tool-scopes-mcp-macos
cd per-tool-scopes-mcp-macos
touch .gitignore

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store

Detailed breakdown

  • Standard Python ignores plus .DS_Store, created first so the virtualenv and caches never get committed.

Step 2: Initialize the project with uv

Create the files

uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio

Add the code: pyproject.toml

[project]
name = "macmcp"
version = "0.1.0"
description = "Fine-grained per-tool authorization (RBAC) for a FastMCP server"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.4",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "pytest-asyncio>=1.4.0",
]

Detailed breakdown

  • FastMCP is the only runtime dependency; it bundles uvicorn, which serving.py uses to run the HTTP server. pytest-asyncio runs the async tests.

Step 3: Identities, roles, and the token verifier

Create the file

touch auth.py

Add the code: auth.py

"""Identities, roles, and the token verifier.

Authorization needs two mappings: identity -> role, and role -> scopes. Roles are
just named bundles of scopes, which keeps per-tool checks about *capabilities*
(`notes:write`) rather than *job titles* (`editor`).

In production the identities and scopes come from your OAuth provider (see the
Clerk and GitHub OAuth articles). Here a `StaticTokenVerifier` stands in: it maps a
fixed set of bearer token strings to the exact claims a real provider would return,
so the whole example runs offline and deterministically. Swap it for a real
verifier and nothing else in the server changes.
"""

from fastmcp.server.auth.providers.jwt import StaticTokenVerifier

# The three capabilities this server understands.
SCOPE_READ = "notes:read"
SCOPE_WRITE = "notes:write"
SCOPE_ADMIN = "notes:admin"

# Roles are named bundles of scopes. A role grants everything below it.
ROLE_SCOPES: dict[str, list[str]] = {
    "reader": [SCOPE_READ],
    "editor": [SCOPE_READ, SCOPE_WRITE],
    "admin": [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN],
}

# The user directory: which token belongs to whom, and what role they hold.
# `client_id` is the stable identity a tool sees; `name` is cosmetic.
USERS: dict[str, dict[str, str]] = {
    "reader-token": {"client_id": "alice", "name": "Alice", "role": "reader"},
    "editor-token": {"client_id": "bob", "name": "Bob", "role": "editor"},
    "admin-token": {"client_id": "carol", "name": "Carol", "role": "admin"},
}


def build_verifier() -> StaticTokenVerifier:
    """Expand each user's role into concrete scopes and build the verifier."""
    tokens: dict[str, dict] = {}
    for token, user in USERS.items():
        tokens[token] = {
            "client_id": user["client_id"],
            "scopes": ROLE_SCOPES[user["role"]],
            "name": user["name"],
            "role": user["role"],
        }
    return StaticTokenVerifier(tokens=tokens)

Detailed breakdown

  • StaticTokenVerifier maps fixed token strings to the claims a real OAuth server would return. build_verifier expands each user’s role into a concrete scope list, so the token reader-token arrives carrying ["notes:read"]. In production this class is the only thing you replace — with a JWT verifier, or a provider like Clerk or GitHub.
  • Roles never appear in the tools. The server checks scopes; roles exist only here, as the map from an identity to the scopes it should carry. Promote Bob to admin by changing one line, and every tool’s authorization follows.
  • client_id is the stable identity a tool reads to answer “who is calling”, used for the ownership check in the next step.

Step 4: The server with per-tool scopes

Create the file

touch server.py

Add the code: server.py

"""A notes server with per-tool scopes and one fine-grained check.

Two layers of authorization:

- **Coarse-grained, declarative**: each tool is gated with
  `auth=require_scopes(...)`. FastMCP hides a tool from any caller who lacks the
  scope — it is absent from that caller's `tools/list` and cannot be called — so a
  reader never even sees the write tools.
- **Fine-grained, imperative**: `delete_note` reads the caller's identity from the
  access token and lets editors delete only their own notes, raising a clear
  `ToolError` otherwise. Admins (holding `notes:admin`) may delete any note. This is
  authorization a static scope cannot express.

Run over HTTP with `python server.py` (needed because auth requires a transport
that carries a bearer token); drive it with client.py.
"""

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.auth.authorization import require_scopes
from fastmcp.server.dependencies import get_access_token

from auth import SCOPE_ADMIN, SCOPE_READ, SCOPE_WRITE, build_verifier

mcp = FastMCP(name="notes", auth=build_verifier())

# Seed data: note id -> {text, owner}. Owner is a client_id from the directory.
_NOTES: dict[int, dict[str, str]] = {
    1: {"text": "buy milk", "owner": "alice"},
    2: {"text": "ship release", "owner": "bob"},
}
_NEXT_ID = 3


def _caller() -> tuple[str, list[str]]:
    """Return the calling identity and its scopes from the verified token."""
    token = get_access_token()
    assert token is not None  # a scoped tool only runs for an authenticated caller
    return token.client_id, list(token.scopes)


@mcp.tool(auth=require_scopes(SCOPE_READ))
def whoami() -> dict:
    """Report the calling identity and the scopes the token carries."""
    client_id, scopes = _caller()
    return {"client_id": client_id, "scopes": scopes}


@mcp.tool(auth=require_scopes(SCOPE_READ))
def list_notes() -> list[dict]:
    """List every note. Requires notes:read."""
    return [{"id": nid, **note} for nid, note in sorted(_NOTES.items())]


@mcp.tool(auth=require_scopes(SCOPE_WRITE))
def create_note(text: str) -> dict:
    """Create a note owned by the caller. Requires notes:write."""
    global _NEXT_ID
    client_id, _ = _caller()
    note = {"text": text, "owner": client_id}
    nid = _NEXT_ID
    _NOTES[nid] = note
    _NEXT_ID += 1
    return {"id": nid, **note}


@mcp.tool(auth=require_scopes(SCOPE_WRITE))
def delete_note(note_id: int) -> dict:
    """Delete a note. Requires notes:write, plus ownership unless notes:admin.

    An editor may delete only notes they own; an admin may delete any note.
    """
    client_id, scopes = _caller()
    note = _NOTES.get(note_id)
    if note is None:
        raise ToolError(f"note {note_id} not found")
    if note["owner"] != client_id and SCOPE_ADMIN not in scopes:
        raise ToolError(
            f"forbidden: note {note_id} is owned by {note['owner']}; "
            f"deleting another user's note needs {SCOPE_ADMIN}"
        )
    del _NOTES[note_id]
    return {"deleted": note_id}


if __name__ == "__main__":
    mcp.run(transport="http", host="127.0.0.1", port=8000)

Detailed breakdown

  • FastMCP(name="notes", auth=build_verifier()) installs the verifier. Every request now needs a bearer token the verifier recognizes, and each token carries the scopes from auth.py.
  • require_scopes(SCOPE_READ) and require_scopes(SCOPE_WRITE) are the coarse-grained gate. require_scopes requires all listed scopes (AND). A reader holds only notes:read, so create_note and delete_note are hidden from them entirely — not merely blocked.
  • get_access_token() returns the verified token inside a handler, with .client_id, .scopes, and any custom claims. _caller() wraps it; the assert documents that a scoped tool only ever runs for an authenticated caller.
  • delete_note’s ownership check is the fine-grained layer: the caller must own the note or hold notes:admin. A static scope cannot express “your own”, so the rule lives in code and raises a ToolError a client can read and act on.
  • mcp.run(transport="http", ...) serves over HTTP, because bearer-token auth needs a transport that carries the Authorization header. The in-memory transport does not.

Step 5: Run the server over HTTP in the background

The demo and the tests both need a running HTTP server. This helper starts one on a free port, waits for it, and shuts it down cleanly.

Create the file

touch serving.py

Add the code: serving.py

"""Run the MCP server over HTTP in a background thread.

Per-tool scopes require a transport that carries a bearer token, so both the demo
and the tests talk to a real HTTP server rather than the in-memory transport. This
helper starts one on an ephemeral port, waits until it is accepting connections,
and shuts it down cleanly. Because uvicorn runs in a thread of this same process,
callers can still reach the server's module state (handy for resetting between
tests).
"""

from __future__ import annotations

import contextlib
import socket
import threading
import time
from collections.abc import Iterator

import uvicorn
from fastmcp import FastMCP


def _free_port() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


@contextlib.contextmanager
def serve_http(mcp: FastMCP) -> Iterator[str]:
    """Yield the base URL of the server, running for the life of the context."""
    port = _free_port()
    config = uvicorn.Config(
        mcp.http_app(), host="127.0.0.1", port=port, log_level="warning"
    )
    server = uvicorn.Server(config)
    thread = threading.Thread(target=server.run, daemon=True)
    thread.start()

    deadline = time.time() + 10
    while not server.started:
        if time.time() > deadline:
            raise RuntimeError("server did not start in time")
        time.sleep(0.02)

    try:
        yield f"http://127.0.0.1:{port}/mcp/"
    finally:
        server.should_exit = True
        thread.join(timeout=5)

Detailed breakdown

  • _free_port() binds to port 0 to let the OS pick an open port, so parallel or repeated runs never collide on a fixed port.
  • mcp.http_app() returns the ASGI app; uvicorn.Server runs it in a daemon thread. Polling server.started avoids a race where the client connects before the socket is listening.
  • The finally block sets should_exit and joins the thread, so the server is always torn down even if the body raises.

Step 6: Drive it as three roles

Create the file

touch client.py

Add the code: client.py

"""Drive the notes server as three different roles.

Starts the server over HTTP, then connects with each role's bearer token and shows
what that role can see and do: a reader sees only read tools, an editor can write
but only delete its own notes, and an admin can delete anyone's.
"""

import asyncio

from fastmcp import Client
from fastmcp.exceptions import ToolError

from serving import serve_http
from server import mcp


async def as_role(url: str, label: str, token: str) -> None:
    async with Client(url, auth=token) as client:
        tools = sorted(t.name for t in await client.list_tools())
        print(f"\n[{label}] visible tools: {tools}")
        who = (await client.call_tool("whoami", {})).data
        print(f"[{label}] whoami: {who}")


async def main() -> None:
    with serve_http(mcp) as url:
        await as_role(url, "reader", "reader-token")
        await as_role(url, "editor", "editor-token")
        await as_role(url, "admin", "admin-token")

        print("\n-- editor tries to delete note 1 (owned by alice) --")
        async with Client(url, auth="editor-token") as client:
            try:
                await client.call_tool("delete_note", {"note_id": 1})
            except ToolError as exc:
                print("  denied:", exc)

        print("-- admin deletes note 1 --")
        async with Client(url, auth="admin-token") as client:
            print("  ", (await client.call_tool("delete_note", {"note_id": 1})).data)

        print("\n-- reader tries to create a note (tool is hidden) --")
        async with Client(url, auth="reader-token") as client:
            try:
                await client.call_tool("create_note", {"text": "nope"})
            except ToolError as exc:
                print("  denied:", exc)


if __name__ == "__main__":
    asyncio.run(main())

Detailed breakdown

  • Client(url, auth=token) sends the token as a bearer credential. Each role connects with its own token, so the server sees a different identity and scope set each time.
  • The visible-tools line is the coarse-grained gate in action: the reader’s list is a strict subset of the editor’s and admin’s.
  • The editor-vs-admin delete shows the fine-grained rule: the same call is denied for the editor (not the owner) and allowed for the admin (notes:admin).
  • The reader’s create_note fails as “Unknown tool” because the tool is hidden, not merely blocked — the reader’s client was never told it exists.

Run it:

uv run python client.py

Expected output (uvicorn log lines on stderr omitted):

[reader] visible tools: ['list_notes', 'whoami']
[reader] whoami: {'client_id': 'alice', 'scopes': ['notes:read']}

[editor] visible tools: ['create_note', 'delete_note', 'list_notes', 'whoami']
[editor] whoami: {'client_id': 'bob', 'scopes': ['notes:read', 'notes:write']}

[admin] visible tools: ['create_note', 'delete_note', 'list_notes', 'whoami']
[admin] whoami: {'client_id': 'carol', 'scopes': ['notes:read', 'notes:write', 'notes:admin']}

-- editor tries to delete note 1 (owned by alice) --
  denied: forbidden: note 1 is owned by alice; deleting another user's note needs notes:admin
-- admin deletes note 1 --
   {'deleted': 1}

-- reader tries to create a note (tool is hidden) --
  denied: Unknown tool: 'create_note'

Step 7: Test the authorization

The server runs once for the whole test session. Because uvicorn runs in a thread of the test process, an autouse fixture can reset the seed notes directly between tests.

Create the files

mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/conftest.py
touch tests/test_authorization.py

Add the code: pytest.ini

[pytest]
asyncio_mode = auto
filterwarnings =
    ignore::DeprecationWarning

Add the code: tests/conftest.py

"""Fixtures for the authorization test suite.

The server runs over HTTP for the whole session (auth needs a token-carrying
transport). Because it runs in a background thread of this process, an autouse
fixture can reset its in-memory notes directly between tests, keeping each test
isolated.
"""

import pytest
import pytest_asyncio
from fastmcp import Client

import server
from serving import serve_http

TOKENS = {
    "reader": "reader-token",
    "editor": "editor-token",
    "admin": "admin-token",
}


@pytest.fixture(scope="session")
def server_url():
    with serve_http(server.mcp) as url:
        yield url


@pytest.fixture(autouse=True)
def reset_notes():
    """Snapshot and restore the seed notes so create/delete tests stay isolated."""
    notes = {k: dict(v) for k, v in server._NOTES.items()}
    next_id = server._NEXT_ID
    yield
    server._NOTES.clear()
    server._NOTES.update({k: dict(v) for k, v in notes.items()})
    server._NEXT_ID = next_id


@pytest_asyncio.fixture
async def connect(server_url):
    """Return an async factory that opens a client for a given role."""
    import contextlib

    @contextlib.asynccontextmanager
    async def _open(role: str):
        async with Client(server_url, auth=TOKENS[role]) as client:
            yield client

    return _open

Detailed breakdown

  • server_url is session-scoped, so the HTTP server starts once and every test reuses it. Starting a server per test would be slow and pointless.
  • reset_notes snapshots and restores server._NOTES and server._NEXT_ID around each test. This works only because the server shares the test process; a subprocess server would need a reset endpoint instead.
  • connect returns a factory so a test writes async with connect("editor") as client: without repeating the URL and token lookup.

Add the code: tests/test_authorization.py

"""Authorization tests: per-tool scopes and one fine-grained ownership check.

Coarse-grained: a caller only sees and can call the tools its scopes allow.
Fine-grained: an editor can delete only its own notes; an admin can delete any.
Authentication: an unknown token is rejected by the transport before any tool runs.
"""

import httpx
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError


# --- Coarse-grained: per-tool scopes gate visibility and calls ----------------


async def test_reader_sees_only_read_tools(connect):
    async with connect("reader") as client:
        tools = {t.name for t in await client.list_tools()}
        assert tools == {"whoami", "list_notes"}


async def test_editor_and_admin_see_write_tools(connect):
    for role in ("editor", "admin"):
        async with connect(role) as client:
            tools = {t.name for t in await client.list_tools()}
            assert {"create_note", "delete_note"} <= tools


async def test_hidden_tool_cannot_be_called(connect):
    """A reader lacks notes:write, so create_note is hidden and uncallable."""
    async with connect("reader") as client:
        with pytest.raises(ToolError, match="Unknown tool"):
            await client.call_tool("create_note", {"text": "nope"})


async def test_reader_can_read(connect):
    async with connect("reader") as client:
        notes = (await client.call_tool("list_notes", {})).data
        assert {n["id"] for n in notes} == {1, 2}


# --- Identity from the token --------------------------------------------------


@pytest.mark.parametrize(
    "role,client_id,scopes",
    [
        ("reader", "alice", ["notes:read"]),
        ("editor", "bob", ["notes:read", "notes:write"]),
        ("admin", "carol", ["notes:read", "notes:write", "notes:admin"]),
    ],
)
async def test_whoami_reports_identity_and_scopes(connect, role, client_id, scopes):
    async with connect(role) as client:
        who = (await client.call_tool("whoami", {})).data
        assert who["client_id"] == client_id
        assert who["scopes"] == scopes


# --- Fine-grained: ownership check inside the handler -------------------------


async def test_editor_creates_note_owned_by_self(connect):
    async with connect("editor") as client:
        note = (await client.call_tool("create_note", {"text": "hi"})).data
        assert note["owner"] == "bob"
        assert note["id"] == 3


async def test_editor_can_delete_own_note(connect):
    async with connect("editor") as client:
        created = (await client.call_tool("create_note", {"text": "mine"})).data
        result = (await client.call_tool("delete_note", {"note_id": created["id"]})).data
        assert result == {"deleted": created["id"]}


async def test_editor_cannot_delete_another_users_note(connect):
    async with connect("editor") as client:
        with pytest.raises(ToolError, match="forbidden"):
            await client.call_tool("delete_note", {"note_id": 1})  # owned by alice


async def test_admin_can_delete_any_note(connect):
    async with connect("admin") as client:
        result = (await client.call_tool("delete_note", {"note_id": 1})).data
        assert result == {"deleted": 1}


# --- Authentication: the token must be valid ----------------------------------


async def test_unknown_token_is_rejected(server_url):
    with pytest.raises(httpx.HTTPStatusError) as exc:
        async with Client(server_url, auth="bogus-token") as client:
            await client.list_tools()
    assert exc.value.response.status_code == 401

Detailed breakdown

  • test_reader_sees_only_read_tools pins the coarse-grained gate: the reader’s tools/list is exactly the two read tools, with the write tools hidden.
  • test_hidden_tool_cannot_be_called confirms hiding is enforcement, not cosmetics — a direct call to the hidden tool fails as “Unknown tool”.
  • test_whoami_reports_identity_and_scopes checks that the verifier’s claims reach the handler intact, parametrized across all three roles.
  • The three delete tests cover the fine-grained rule from every angle: an editor deletes its own note, is refused another user’s (forbidden), and an admin deletes any note.
  • test_unknown_token_is_rejected is the authentication floor: a bad token never reaches a tool; the transport answers 401.

Step 8: Wrap it in a Makefile

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help install demo serve test clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

install:  ## Sync runtime and dev dependencies
	uv sync

demo:  ## Start the server and drive it as reader, editor, and admin
	uv run python client.py

serve:  ## Run the server over HTTP on 127.0.0.1:8000 (for a real MCP client)
	uv run python server.py

test:  ## Run the authorization test suite
	uv run pytest -q

clean:  ## Remove caches
	rm -rf .pytest_cache __pycache__ tests/__pycache__

Detailed breakdown

  • .DEFAULT_GOAL := help makes a bare make print the help screen. Recipe bodies must be tab-indented or make errors.

Step 9: Run everything

make          # prints the help screen
make demo     # runs the server and drives it as three roles (output in Step 6)
make test     # runs the suite

The suite passes:

............                                                             [100%]
12 passed in 0.42s

Notes on real deployments

  • Swap the verifier, keep the tools. StaticTokenVerifier is for local development and tests. In production use a JWT verifier or a provider (Clerk, GitHub, WorkOS); the tokens then carry real scopes and the require_scopes gates are unchanged.
  • Scopes must actually be issued. Per-tool gating only works if your identity provider mints the scopes. Map roles to scopes at the provider (or in a claims transform) so a token arrives with the right set.
  • Hiding vs. denying is a choice. FastMCP hides unauthorized tools, which avoids leaking their existence. If you would rather return an explicit “forbidden”, do the check inside the handler and raise a ToolError, as delete_note does for ownership.
  • Fine-grained rules belong in the handler. Row-level and ownership rules cannot be expressed as static scopes. Read the identity from get_access_token() and enforce them in code, with clear error messages.
  • Least privilege by default. Give each role the smallest scope set that does its job, and require the narrowest scope on each tool.

Troubleshooting

  • Every call returns 401. The client sent no token or an unknown one. Pass auth="<token>" to Client, and confirm the token is a key in USERS.
  • A tool you expected is missing from tools/list. That identity lacks the tool’s scope, so it is hidden by design. Check the role→scope map in auth.py.
  • get_access_token() returns None. The call ran without authentication. Every tool here requires a scope, so this should not happen in a real call; it can if you call a handler directly in a unit test instead of through the client.
  • ValueError: This transport does not support auth. You connected Client to the in-memory server object instead of an HTTP URL. Auth needs the HTTP transport; use the URL from serve_http.
  • ModuleNotFoundError: No module named 'server' under pytest. tests/ needs an __init__.py so pytest puts the project root on sys.path.

Recap

Authorization got two layers. Roles mapped identities to scopes in one place; require_scopes gated each tool, so a caller only sees and can call the tools its scopes allow, and unauthorized tools are hidden rather than merely blocked. For the rule a scope cannot express (an editor deleting only their own notes), the handler read the caller’s identity from the verified token and raised a clear ToolError. Underneath both, authentication rejected an unknown token with 401 before any tool ran. Replace the static verifier with a real provider and the whole authorization model carries over unchanged.

Next improvements:

  • Issue scopes from a real provider (see the Clerk and GitHub OAuth articles) and delete the static verifier.
  • Add restrict_tag("admin", scopes=[...]) to gate a whole group of tagged tools at once instead of tool by tool.
  • Log every denied call with the caller’s client_id for an audit trail.