|
| 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