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’stools/listand 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
ToolErrorwith 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
pytestsuite proving visibility, calls, identity, ownership, and auth.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - 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, whichserving.pyuses to run the HTTP server.pytest-asyncioruns 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
StaticTokenVerifiermaps fixed token strings to the claims a real OAuth server would return.build_verifierexpands each user’s role into a concrete scope list, so the tokenreader-tokenarrives 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_idis 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 fromauth.py.require_scopes(SCOPE_READ)andrequire_scopes(SCOPE_WRITE)are the coarse-grained gate.require_scopesrequires all listed scopes (AND). A reader holds onlynotes:read, socreate_noteanddelete_noteare 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; theassertdocuments 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 holdnotes:admin. A static scope cannot express “your own”, so the rule lives in code and raises aToolErrora client can read and act on.mcp.run(transport="http", ...)serves over HTTP, because bearer-token auth needs a transport that carries theAuthorizationheader. 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.Serverruns it in a daemon thread. Pollingserver.startedavoids a race where the client connects before the socket is listening.- The
finallyblock setsshould_exitand 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_notefails 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_urlis session-scoped, so the HTTP server starts once and every test reuses it. Starting a server per test would be slow and pointless.reset_notessnapshots and restoresserver._NOTESandserver._NEXT_IDaround each test. This works only because the server shares the test process; a subprocess server would need a reset endpoint instead.connectreturns a factory so a test writesasync 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_toolspins the coarse-grained gate: the reader’stools/listis exactly the two read tools, with the write tools hidden.test_hidden_tool_cannot_be_calledconfirms hiding is enforcement, not cosmetics — a direct call to the hidden tool fails as “Unknown tool”.test_whoami_reports_identity_and_scopeschecks 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_rejectedis the authentication floor: a bad token never reaches a tool; the transport answers401.
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 := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
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.
StaticTokenVerifieris for local development and tests. In production use a JWT verifier or a provider (Clerk, GitHub, WorkOS); the tokens then carry real scopes and therequire_scopesgates 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, asdelete_notedoes 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>"toClient, and confirm the token is a key inUSERS. - 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 inauth.py. get_access_token()returnsNone. 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 connectedClientto the in-memory server object instead of an HTTP URL. Auth needs the HTTP transport; use the URL fromserve_http.ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.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_idfor an audit trail.