Repository navigation
Expand file tree
/
Copy pathtutorial003.py
More file actions
156 lines (128 loc) · 5.38 KB
/
Copy pathtutorial003.py
File metadata and controls
156 lines (128 loc) · 5.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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()