Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/run/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,70 @@ Call `whoami` with `Authorization: Bearer alice-token` and the model reads:
alice (scopes: notes:read)
```

## Identity, discovery, and permission to execute

A valid token identifies the caller; it does not grant every operation. `tools/list`
controls what a client discovers. Each `tools/call` must still authorize the operation
and the specific data it touches, even if the caller guesses a hidden tool's name.

This local example has two tools with explicit arguments: `notes_read(note_id)` and
`notes_update(note_id, text)`. The tenant comes from the verified token's claims,
and note ownership comes from server data. Neither is a model-supplied argument.
`client_id` identifies the OAuth client; it is not a tenant or necessarily an end user.

```python title="server.py"
--8<-- "docs_src/authorization/tutorial003.py"
```

The four decisions happen at different boundaries:

| Boundary | Decision |
| --- | --- |
| HTTP authentication | Accept only a verified token issued for this resource. |
| Tool discovery | List only tools whose operation scope the token carries. |
| Tool dispatch | Refuse an unconfigured tool or a call without its required scope, before entering the handler. |
| Tool execution | Check scope again and match the target note's tenant before reading or changing its contents. |

`required_scopes=[]` keeps authentication mandatory while leaving operation scopes
to the application. Requiring both `notes:read` and `notes:write` there would reject
read-only callers at the HTTP boundary. The same `TOOL_SCOPES` mapping drives
discovery and execution; a newly registered tool is denied until a rule is added.

!!! warning
The [middleware API](../advanced/middleware.md) is provisional. This example
uses it to filter discovery and refuse calls early, and keeps authorization
in the tool handlers too. Hiding a tool is never the authorization boundary.
Do not share a filtered tool-list cache across callers or permission changes.

Run the example with `uv run --frozen mcp run docs_src/authorization/tutorial003.py
--transport streamable-http` (see [Running your server](index.md)). Connect an HTTP
client with one of these **fake local demonstration tokens**:

| Bearer token | Visible tools | Example outcome |
| --- | --- | --- |
| `reader-token` | `notes_read` | Reads `note-1`; a direct `notes_update` call is refused. |
| `writer-token` | `notes_read`, `notes_update` | Can read or update `note-1`; access to `note-2` is refused. |
| `other-tenant-token` | `notes_read` | Reads `note-2`; access to `note-1` is refused. |

Missing or invalid tokens, including a token for another resource, receive HTTP
401 before MCP dispatch. A valid token without an allowed operation or tenant gets
the application's JSON-RPC error `PERMISSION_DENIED` (`1`), with
`"Operation not permitted."`. This code is application-defined, not an MCP standard.
Missing and foreign notes get the same error without resource details. `MCPError`
goes to the client application; it is not a model-visible `is_error=True` tool result.
The client should handle denial rather than repeatedly retrying with invented identity.

!!! warning
Never deploy the static token table. A production verifier must validate the
issuer, signature or introspection response, expiry, audience, and trusted tenant
claims. This example is an authorization pattern, not a sandbox. For persistent
data, enforce ownership in the same database operation as the read or update
(for example, match both note ID and authenticated tenant) so it cannot change
between a permission check and a write. Keep tokens and note contents out of logs.

Without HTTP authentication, including with `Client(mcp)` or over stdio, this
example refuses note operations because it has no trusted identity.

## The half the SDK doesn't do

The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token.
Expand Down
156 changes: 156 additions & 0 deletions docs_src/authorization/tutorial003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from dataclasses import dataclass

from pydantic import AnyHttpUrl

from mcp import MCPError
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext

RESOURCE = "http://127.0.0.1:8000/mcp"
PERMISSION_DENIED = 1 # Application-defined; MCP has no standard permission-denied code.
TOOL_SCOPES = {"notes_read": "notes:read", "notes_update": "notes:write"}

# Fake tokens for a local demonstration only. Never deploy this verifier.
KNOWN_TOKENS = {
"reader-token": AccessToken(
token="reader-token",
client_id="demo",
subject="alice",
scopes=["notes:read"],
resource=RESOURCE,
claims={"tenant_id": "tenant-a"},
),
"writer-token": AccessToken(
token="writer-token",
client_id="demo",
subject="alice",
scopes=["notes:read", "notes:write"],
resource=RESOURCE,
claims={"tenant_id": "tenant-a"},
),
"other-tenant-token": AccessToken(
token="other-tenant-token",
client_id="demo",
subject="bob",
scopes=["notes:read"],
resource=RESOURCE,
claims={"tenant_id": "tenant-b"},
),
}


class StaticTokenVerifier(TokenVerifier):
"""Look up fake tokens; a production verifier must validate issuer and signature."""

async def verify_token(self, token: str) -> AccessToken | None:
"""Return trusted claims only for a recognized demonstration token."""
return KNOWN_TOKENS.get(token)


def authorize_tool(name: str) -> str:
"""Return the trusted tenant for an allowed operation.

Raises:
MCPError: If identity, operation scope or tenant context is missing.
"""
token = get_access_token()
scope = TOOL_SCOPES.get(name)
tenant = (token.claims or {}).get("tenant_id") if token is not None else None
if token is None or scope is None or scope not in token.scopes or not isinstance(tenant, str) or not tenant:
raise MCPError(code=PERMISSION_DENIED, message="Operation not permitted.")
return tenant


async def tool_permissions(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
"""Filter discovery and independently gate tool calls before dispatch.

Raises:
MCPError: If the caller cannot discover tools or execute the named operation.
"""
if ctx.method == "tools/call":
name = (ctx.params or {}).get("name")
# Params are raw here; resource decisions belong in the validated handler.
authorize_tool(name if isinstance(name, str) else "")
result = await call_next(ctx)
if ctx.method == "tools/list":
token = get_access_token()
if token is None:
raise MCPError(code=PERMISSION_DENIED, message="Operation not permitted.")
if not isinstance(result, dict):
raise RuntimeError("Expected the completed tools/list response")
# Preserve the response envelope, including the SDK's serverInfo metadata.
result = {
**result,
"tools": [tool for tool in result["tools"] if TOOL_SCOPES.get(tool["name"]) in token.scopes],
}
return result


@dataclass
class Note:
"""Server-owned note data; callers cannot choose its tenant."""

tenant_id: str
text: str


def create_server() -> MCPServer:
"""Build a local demonstration with isolated in-memory note data."""
notes = {
"note-1": Note(tenant_id="tenant-a", text="Ship the release"),
"note-2": Note(tenant_id="tenant-b", text="Private tenant B note"),
}
server = MCPServer(
"Notes",
token_verifier=StaticTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl(RESOURCE),
required_scopes=[], # Operation scopes are checked separately.
validate_token_resource=True,
),
middleware=[tool_permissions],
)

def authorized_note(name: str, note_id: str) -> Note:
tenant = authorize_tool(name)
note = notes.get(note_id)
if note is None or note.tenant_id != tenant:
# The same denial avoids revealing whether another tenant's note exists.
raise MCPError(code=PERMISSION_DENIED, message="Operation not permitted.")
return note

@server.tool()
def notes_read(note_id: str) -> str:
"""Read a note by ID in the authenticated tenant; requires notes:read.

Args:
note_id: ID of the note to read. Tenant identity comes from the verified token.

Raises:
MCPError: If scope or ownership does not permit access.
"""
return authorized_note("notes_read", note_id).text

@server.tool()
def notes_update(note_id: str, text: str) -> str:
"""Replace a note's text in the authenticated tenant; requires notes:write.

Args:
note_id: ID of the note to update. Tenant identity comes from the verified token.
text: New text replacing the note's current contents.

Raises:
MCPError: If scope or ownership does not permit access.
"""
note = authorized_note("notes_update", note_id)
note.text = text
return note.text

return server


mcp = create_server()
Loading
Loading