Skip to content

Commit b9f7016

Browse files
committed
docs: separate tool discovery and execution authorization
1 parent 91941ed commit b9f7016

3 files changed

Lines changed: 380 additions & 2 deletions

File tree

‎docs/run/authorization.md‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,70 @@ Call `whoami` with `Authorization: Bearer alice-token` and the model reads:
104104
alice (scopes: notes:read)
105105
```
106106

107+
## Identity, discovery, and permission to execute
108+
109+
A valid token identifies the caller; it does not grant every operation. `tools/list`
110+
controls what a client discovers. Each `tools/call` must still authorize the operation
111+
and the specific data it touches, even if the caller guesses a hidden tool's name.
112+
113+
This local example has two tools with explicit arguments: `notes_read(note_id)` and
114+
`notes_update(note_id, text)`. The tenant comes from the verified token's claims,
115+
and note ownership comes from server data. Neither is a model-supplied argument.
116+
`client_id` identifies the OAuth client; it is not a tenant or necessarily an end user.
117+
118+
```python title="server.py"
119+
--8<-- "docs_src/authorization/tutorial003.py"
120+
```
121+
122+
The four decisions happen at different boundaries:
123+
124+
| Boundary | Decision |
125+
| --- | --- |
126+
| HTTP authentication | Accept only a verified token issued for this resource. |
127+
| Tool discovery | List only tools whose operation scope the token carries. |
128+
| Tool dispatch | Refuse an unconfigured tool or a call without its required scope, before entering the handler. |
129+
| Tool execution | Check scope again and match the target note's tenant before reading or changing its contents. |
130+
131+
`required_scopes=[]` keeps authentication mandatory while leaving operation scopes
132+
to the application. Requiring both `notes:read` and `notes:write` there would reject
133+
read-only callers at the HTTP boundary. The same `TOOL_SCOPES` mapping drives
134+
discovery and execution; a newly registered tool is denied until a rule is added.
135+
136+
!!! warning
137+
The [middleware API](../advanced/middleware.md) is provisional. This example
138+
uses it to filter discovery and refuse calls early, and keeps authorization
139+
in the tool handlers too. Hiding a tool is never the authorization boundary.
140+
Do not share a filtered tool-list cache across callers or permission changes.
141+
142+
Run the example with `uv run --frozen mcp run docs_src/authorization/tutorial003.py
143+
--transport streamable-http` (see [Running your server](index.md)). Connect an HTTP
144+
client with one of these **fake local demonstration tokens**:
145+
146+
| Bearer token | Visible tools | Example outcome |
147+
| --- | --- | --- |
148+
| `reader-token` | `notes_read` | Reads `note-1`; a direct `notes_update` call is refused. |
149+
| `writer-token` | `notes_read`, `notes_update` | Can read or update `note-1`; access to `note-2` is refused. |
150+
| `other-tenant-token` | `notes_read` | Reads `note-2`; access to `note-1` is refused. |
151+
152+
Missing or invalid tokens, including a token for another resource, receive HTTP
153+
401 before MCP dispatch. A valid token without an allowed operation or tenant gets
154+
the application's JSON-RPC error `PERMISSION_DENIED` (`1`), with
155+
`"Operation not permitted."`. This code is application-defined, not an MCP standard.
156+
Missing and foreign notes get the same error without resource details. `MCPError`
157+
goes to the client application; it is not a model-visible `is_error=True` tool result.
158+
The client should handle denial rather than repeatedly retrying with invented identity.
159+
160+
!!! warning
161+
Never deploy the static token table. A production verifier must validate the
162+
issuer, signature or introspection response, expiry, audience, and trusted tenant
163+
claims. This example is an authorization pattern, not a sandbox. For persistent
164+
data, enforce ownership in the same database operation as the read or update
165+
(for example, match both note ID and authenticated tenant) so it cannot change
166+
between a permission check and a write. Keep tokens and note contents out of logs.
167+
168+
Without HTTP authentication, including with `Client(mcp)` or over stdio, this
169+
example refuses note operations because it has no trusted identity.
170+
107171
## The half the SDK doesn't do
108172

109173
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.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from dataclasses import dataclass
2+
3+
from pydantic import AnyHttpUrl
4+
5+
from mcp import MCPError
6+
from mcp.server import MCPServer
7+
from mcp.server.auth.middleware.auth_context import get_access_token
8+
from mcp.server.auth.provider import AccessToken, TokenVerifier
9+
from mcp.server.auth.settings import AuthSettings
10+
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
11+
12+
RESOURCE = "http://127.0.0.1:8000/mcp"
13+
PERMISSION_DENIED = 1 # Application-defined; MCP has no standard permission-denied code.
14+
TOOL_SCOPES = {"notes_read": "notes:read", "notes_update": "notes:write"}
15+
16+
# Fake tokens for a local demonstration only. Never deploy this verifier.
17+
KNOWN_TOKENS = {
18+
"reader-token": AccessToken(
19+
token="reader-token",
20+
client_id="demo",
21+
subject="alice",
22+
scopes=["notes:read"],
23+
resource=RESOURCE,
24+
claims={"tenant_id": "tenant-a"},
25+
),
26+
"writer-token": AccessToken(
27+
token="writer-token",
28+
client_id="demo",
29+
subject="alice",
30+
scopes=["notes:read", "notes:write"],
31+
resource=RESOURCE,
32+
claims={"tenant_id": "tenant-a"},
33+
),
34+
"other-tenant-token": AccessToken(
35+
token="other-tenant-token",
36+
client_id="demo",
37+
subject="bob",
38+
scopes=["notes:read"],
39+
resource=RESOURCE,
40+
claims={"tenant_id": "tenant-b"},
41+
),
42+
}
43+
44+
45+
class StaticTokenVerifier(TokenVerifier):
46+
"""Look up fake tokens; a production verifier must validate issuer and signature."""
47+
48+
async def verify_token(self, token: str) -> AccessToken | None:
49+
"""Return trusted claims only for a recognized demonstration token."""
50+
return KNOWN_TOKENS.get(token)
51+
52+
53+
def authorize_tool(name: str) -> str:
54+
"""Return the trusted tenant for an allowed operation.
55+
56+
Raises:
57+
MCPError: If identity, operation scope or tenant context is missing.
58+
"""
59+
token = get_access_token()
60+
scope = TOOL_SCOPES.get(name)
61+
tenant = (token.claims or {}).get("tenant_id") if token is not None else None
62+
if token is None or scope is None or scope not in token.scopes or not isinstance(tenant, str) or not tenant:
63+
raise MCPError(code=PERMISSION_DENIED, message="Operation not permitted.")
64+
return tenant
65+
66+
67+
async def tool_permissions(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
68+
"""Filter discovery and independently gate tool calls before dispatch.
69+
70+
Raises:
71+
MCPError: If the caller cannot discover tools or execute the named operation.
72+
"""
73+
if ctx.method == "tools/call":
74+
name = (ctx.params or {}).get("name")
75+
# Params are raw here; resource decisions belong in the validated handler.
76+
authorize_tool(name if isinstance(name, str) else "")
77+
result = await call_next(ctx)
78+
if ctx.method == "tools/list":
79+
token = get_access_token()
80+
if token is None:
81+
raise MCPError(code=PERMISSION_DENIED, message="Operation not permitted.")
82+
if not isinstance(result, dict):
83+
raise RuntimeError("Expected the completed tools/list response")
84+
# Preserve the response envelope, including the SDK's serverInfo metadata.
85+
result = {
86+
**result,
87+
"tools": [tool for tool in result["tools"] if TOOL_SCOPES.get(tool["name"]) in token.scopes],
88+
}
89+
return result
90+
91+
92+
@dataclass
93+
class Note:
94+
"""Server-owned note data; callers cannot choose its tenant."""
95+
96+
tenant_id: str
97+
text: str
98+
99+
100+
def create_server() -> MCPServer:
101+
"""Build a local demonstration with isolated in-memory note data."""
102+
notes = {
103+
"note-1": Note(tenant_id="tenant-a", text="Ship the release"),
104+
"note-2": Note(tenant_id="tenant-b", text="Private tenant B note"),
105+
}
106+
server = MCPServer(
107+
"Notes",
108+
token_verifier=StaticTokenVerifier(),
109+
auth=AuthSettings(
110+
issuer_url=AnyHttpUrl("https://auth.example.com"),
111+
resource_server_url=AnyHttpUrl(RESOURCE),
112+
required_scopes=[], # Operation scopes are checked separately.
113+
validate_token_resource=True,
114+
),
115+
middleware=[tool_permissions],
116+
)
117+
118+
def authorized_note(name: str, note_id: str) -> Note:
119+
tenant = authorize_tool(name)
120+
note = notes.get(note_id)
121+
if note is None or note.tenant_id != tenant:
122+
# The same denial avoids revealing whether another tenant's note exists.
123+
raise MCPError(code=PERMISSION_DENIED, message="Operation not permitted.")
124+
return note
125+
126+
@server.tool()
127+
def notes_read(note_id: str) -> str:
128+
"""Read a note by ID in the authenticated tenant; requires notes:read.
129+
130+
Args:
131+
note_id: ID of the note to read. Tenant identity comes from the verified token.
132+
133+
Raises:
134+
MCPError: If scope or ownership does not permit access.
135+
"""
136+
return authorized_note("notes_read", note_id).text
137+
138+
@server.tool()
139+
def notes_update(note_id: str, text: str) -> str:
140+
"""Replace a note's text in the authenticated tenant; requires notes:write.
141+
142+
Args:
143+
note_id: ID of the note to update. Tenant identity comes from the verified token.
144+
text: New text replacing the note's current contents.
145+
146+
Raises:
147+
MCPError: If scope or ownership does not permit access.
148+
"""
149+
note = authorized_note("notes_update", note_id)
150+
note.text = text
151+
return note.text
152+
153+
return server
154+
155+
156+
mcp = create_server()

0 commit comments

Comments
 (0)