From b9f7016db8f2cbcf75d16e2726540fff280927d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E7=BA=AC=E8=88=AA?= <2946843254@qq.com> Date: Tue, 6 Oct 2026 11:52:51 +0000 Subject: [PATCH] docs: separate tool discovery and execution authorization --- docs/run/authorization.md | 64 ++++++++++ docs_src/authorization/tutorial003.py | 156 +++++++++++++++++++++++++ tests/docs_src/test_authorization.py | 162 +++++++++++++++++++++++++- 3 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 docs_src/authorization/tutorial003.py diff --git a/docs/run/authorization.md b/docs/run/authorization.md index fefd0ed34a..6ad8d0d897 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -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. diff --git a/docs_src/authorization/tutorial003.py b/docs_src/authorization/tutorial003.py new file mode 100644 index 0000000000..2c6253f2bc --- /dev/null +++ b/docs_src/authorization/tutorial003.py @@ -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() diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py index 00c9adc81c..efc7e619ec 100644 --- a/tests/docs_src/test_authorization.py +++ b/tests/docs_src/test_authorization.py @@ -1,20 +1,43 @@ """`docs/run/authorization.md`: every claim the page makes, proved against the real SDK.""" +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + import httpx2 import pytest from inline_snapshot import snapshot from mcp_types import TextContent from starlette.routing import Route -from docs_src.authorization import tutorial001, tutorial002 -from mcp import Client +from docs_src.authorization import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError from mcp.client.streamable_http import streamable_http_client from mcp.server import MCPServer +from mcp.server.auth.provider import AccessToken # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] +@pytest.fixture +async def notes_app() -> AsyncIterator[tuple[MCPServer, httpx2.ASGITransport]]: + server = tutorial003.create_server() + transport = httpx2.ASGITransport(app=server.streamable_http_app()) + async with server.session_manager.run(): + yield server, transport + + +@asynccontextmanager +async def notes_client(transport: httpx2.ASGITransport, token: str) -> AsyncIterator[Client]: + async with ( + httpx2.AsyncClient( + transport=transport, base_url=tutorial003.RESOURCE, headers={"Authorization": f"Bearer {token}"} + ) as http_client, + Client(streamable_http_client(tutorial003.RESOURCE, http_client=http_client)) as client, + ): + yield client + + async def test_the_in_memory_client_never_authenticates() -> None: """tutorial001: `Client(mcp)` connects to the server object directly, so no token is ever checked.""" async with Client(tutorial001.mcp) as client: @@ -96,3 +119,138 @@ async def test_get_access_token_is_the_callers_access_token() -> None: result = await client.call_tool("whoami", {}) assert result.content == [TextContent(type="text", text="alice (scopes: notes:read)")] assert result.structured_content == {"result": "alice (scopes: notes:read)"} + + +async def test_tool_discovery_reflects_the_current_callers_scopes( + notes_app: tuple[MCPServer, httpx2.ASGITransport], +) -> None: + """tutorial003 policy: a shared server filters discovery separately as callers change.""" + _, transport = notes_app + for token, visible in [ + ("reader-token", ["notes_read"]), + ("writer-token", ["notes_read", "notes_update"]), + ("reader-token", ["notes_read"]), + ]: + async with notes_client(transport, token) as client: + tools = await client.list_tools() + assert [tool.name for tool in tools.tools] == visible + assert tools.meta is not None + assert "io.modelcontextprotocol/serverInfo" in tools.meta + result = await client.call_tool("notes_read", {"note_id": "note-1"}) + assert result.structured_content == {"result": "Ship the release"} + + +@pytest.mark.parametrize("name", ["notes_update", "unconfigured_tool"]) +async def test_a_guessed_or_unconfigured_tool_is_denied_before_its_handler_runs( + notes_app: tuple[MCPServer, httpx2.ASGITransport], name: str +) -> None: + """tutorial003 policy: knowing a tool name cannot bypass the scope gate before dispatch.""" + server, transport = notes_app + + def forbidden_handler(note_id: str, text: str) -> str: + raise NotImplementedError + + if name == "notes_update": + server.remove_tool(name) + server.add_tool(forbidden_handler, name=name) + async with notes_client(transport, "reader-token") as client: + with pytest.raises(MCPError) as exc: + await client.call_tool(name, {"note_id": "note-1", "text": "changed"}) + assert exc.value.error.code == tutorial003.PERMISSION_DENIED + assert exc.value.error.message == snapshot("Operation not permitted.") + assert exc.value.error.data is None + + +async def test_a_writer_can_update_and_read_a_note_in_its_own_tenant( + notes_app: tuple[MCPServer, httpx2.ASGITransport], +) -> None: + """tutorial003 policy: write scope plus ownership permits the mutation and subsequent read.""" + _, transport = notes_app + text = "Updated release checklist" + async with notes_client(transport, "writer-token") as client: + result = await client.call_tool("notes_update", {"note_id": "note-1", "text": text}) + assert result.structured_content == {"result": text} + result = await client.call_tool("notes_read", {"note_id": "note-1"}) + assert result.structured_content == {"result": text} + + +@pytest.mark.parametrize("note_id", ["note-2", "missing-note"]) +@pytest.mark.parametrize("name", ["notes_read", "notes_update"]) +async def test_foreign_and_missing_notes_return_the_same_safe_denial( + notes_app: tuple[MCPServer, httpx2.ASGITransport], name: str, note_id: str +) -> None: + """tutorial003 policy: ownership is required even with scope, and denial hides note existence.""" + _, transport = notes_app + arguments = {"note_id": note_id} + if name == "notes_update": + arguments["text"] = "changed" + async with notes_client(transport, "writer-token") as client: + with pytest.raises(MCPError) as exc: + await client.call_tool(name, arguments) + assert exc.value.error.code == tutorial003.PERMISSION_DENIED + assert exc.value.error.message == snapshot("Operation not permitted.") + assert exc.value.error.data is None + async with notes_client(transport, "other-tenant-token") as client: + result = await client.call_tool("notes_read", {"note_id": "note-2"}) + assert result.structured_content == {"result": "Private tenant B note"} + + +@pytest.mark.parametrize("tenant", [None, "", 42]) +async def test_a_valid_token_without_a_valid_tenant_claim_cannot_access_notes( + notes_app: tuple[MCPServer, httpx2.ASGITransport], monkeypatch: pytest.MonkeyPatch, tenant: str | int | None +) -> None: + """tutorial003 policy: a verified token alone is insufficient without trusted tenant context.""" + token = AccessToken( + token="no-tenant", + client_id="demo", + scopes=["notes:read", "notes:write"], + resource=tutorial003.RESOURCE, + claims={"tenant_id": tenant}, + ) + monkeypatch.setitem(tutorial003.KNOWN_TOKENS, token.token, token) + _, transport = notes_app + async with notes_client(transport, token.token) as client: + with pytest.raises(MCPError) as exc: + await client.call_tool("notes_read", {"note_id": "note-1"}) + assert exc.value.error.code == tutorial003.PERMISSION_DENIED + + +@pytest.mark.parametrize("token", [None, "unknown-token", "wrong-audience"]) +async def test_untrusted_http_identity_is_rejected_before_mcp_dispatch( + notes_app: tuple[MCPServer, httpx2.ASGITransport], monkeypatch: pytest.MonkeyPatch, token: str | None +) -> None: + """tutorial003 uses SDK HTTP authentication; raw HTTP observes the pre-protocol 401.""" + monkeypatch.setitem( + tutorial003.KNOWN_TOKENS, + "wrong-audience", + AccessToken( + token="wrong-audience", client_id="demo", scopes=["notes:read"], resource="https://other.example/mcp" + ), + ) + _, transport = notes_app + headers = {} if token is None else {"Authorization": f"Bearer {token}"} + async with httpx2.AsyncClient(transport=transport, base_url=tutorial003.RESOURCE) as http_client: + response = await http_client.post(tutorial003.RESOURCE, json={}, headers=headers) + assert response.status_code == 401 + + +async def test_in_memory_calls_fail_closed_without_http_identity() -> None: + """tutorial003 policy: transport bypass does not create an authenticated principal.""" + async with Client(tutorial003.create_server()) as client: + with pytest.raises(MCPError) as exc: + await client.call_tool("notes_read", {"note_id": "note-1"}) + assert exc.value.error.code == tutorial003.PERMISSION_DENIED + + +async def test_handler_authorization_still_denies_writes_without_the_discovery_middleware( + notes_app: tuple[MCPServer, httpx2.ASGITransport], +) -> None: + """tutorial003 policy: handler scope checks remain effective independently of provisional middleware.""" + server, transport = notes_app + server.middleware.remove(tutorial003.tool_permissions) + async with notes_client(transport, "reader-token") as client: + with pytest.raises(MCPError) as exc: + await client.call_tool("notes_update", {"note_id": "note-1", "text": "changed"}) + assert exc.value.error.code == tutorial003.PERMISSION_DENIED + result = await client.call_tool("notes_read", {"note_id": "note-1"}) + assert result.structured_content == {"result": "Ship the release"}