chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# bearer-auth
|
||||
|
||||
Resource-server-only bearer auth. Pass a `TokenVerifier` + `AuthSettings`
|
||||
(issuer, resource URL, required scopes) when building the streamable-HTTP app
|
||||
and the SDK wires three things automatically: a bearer gate that answers 401 +
|
||||
`WWW-Authenticate: Bearer ... resource_metadata=...` (or 403 `insufficient_scope`),
|
||||
the RFC 9728 protected-resource-metadata document at
|
||||
`/.well-known/oauth-protected-resource/mcp`, and the verified `AccessToken`
|
||||
inside tool handlers via `get_access_token()`. The verifier here accepts one
|
||||
static token — replace it with JWT verification or RFC 7662 introspection. No
|
||||
authorization server; see `../oauth/` for the full grant flow.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# HTTP — the client self-hosts the bearer-gated app, connects with the demo
|
||||
# bearer token, then tears it down. Self-hosting uses this story's fixed :8000
|
||||
# (the issuer/PRM metadata pin it), so :8000 must be free.
|
||||
uv run python -m stories.bearer_auth.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.bearer_auth.client --http --server server_lowlevel
|
||||
|
||||
# against a server you run yourself (real uvicorn on :8000). The next section's
|
||||
# curl probes use it too and `kill` it when done. While it is up it owns :8000,
|
||||
# so the two self-host lines above refuse to run rather than test it by mistake.
|
||||
uv run python -m stories.bearer_auth.server --port 8000 &
|
||||
SERVER_PID=$!
|
||||
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
|
||||
```
|
||||
|
||||
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
|
||||
can't carry the token. Both runners close that gap the same way: `run_client`
|
||||
(above) and the pytest harness thread the module's `build_auth` export onto the
|
||||
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
|
||||
already routed through it.
|
||||
|
||||
## Try it without the SDK client
|
||||
|
||||
```bash
|
||||
# no token → 401 + WWW-Authenticate pointing at the PRM document
|
||||
curl -i -X POST http://127.0.0.1:8000/mcp \
|
||||
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
|
||||
|
||||
# the RFC 9728 protected-resource-metadata document
|
||||
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq
|
||||
|
||||
# done with the server you started in "Run it"
|
||||
kill "$SERVER_PID"
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
|
||||
client:` and that is the whole program. The `target` it receives is a
|
||||
transport that already carries the bearer token; nothing in the body knows
|
||||
auth exists.
|
||||
- `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is
|
||||
five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK
|
||||
is missing; until it lands, the auth has to be threaded onto the
|
||||
`httpx.AsyncClient` underneath the transport, outside `main`.
|
||||
- `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the
|
||||
whole recipe; `streamable_http_app()` reads those constructor kwargs and
|
||||
mounts the bearer gate + PRM route.
|
||||
- `server_lowlevel.py` — same gate, but `lowlevel.Server` takes
|
||||
`auth=` / `token_verifier=` at **`streamable_http_app(...)` time**, not in the
|
||||
constructor. `mcp.server.auth.*` imports are allowed in lowlevel files
|
||||
(helper-tier).
|
||||
- `whoami()` — `get_access_token()` returns the per-HTTP-request `AccessToken`.
|
||||
It is **not** on `Context` (unlike other SDKs' `ctx.authInfo`); a later
|
||||
release will namespace it as `ctx.transport.auth`.
|
||||
|
||||
## Caveats
|
||||
|
||||
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
|
||||
for localhost binds; the harness disables it because the in-process httpx
|
||||
client sends no `Origin` header. Drop the kwarg for a real deployment.
|
||||
- `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin).
|
||||
If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's
|
||||
`resource` field will be wrong.
|
||||
- Auth is HTTP-only; over stdio or the in-memory transport `get_access_token()`
|
||||
returns `None` and there is no gate.
|
||||
- The 401/403 status codes and `WWW-Authenticate` header are HTTP-level and
|
||||
`Client` cannot observe them; they are pinned by
|
||||
`tests/interaction/auth/test_bearer.py` and shown via `curl` above.
|
||||
|
||||
## Spec
|
||||
|
||||
[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
|
||||
· RFC 9728 (Protected Resource Metadata) · RFC 6750 (`WWW-Authenticate: Bearer`)
|
||||
|
||||
## See also
|
||||
|
||||
`oauth/` (full authorization-code grant with an in-process AS) ·
|
||||
`oauth_client_credentials/` (M2M `client_credentials` grant) ·
|
||||
`stateless_legacy/` (the un-gated hosting baseline).
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Call the bearer-gated server through an already-authed (``build_auth``, HTTP-only) transport; assert ``whoami``."""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import httpx
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
from .server import DEMO_TOKEN, REQUIRED_SCOPE
|
||||
|
||||
|
||||
class StaticBearerAuth(httpx.Auth):
|
||||
"""``httpx.Auth`` that attaches a fixed ``Authorization: Bearer <token>`` to every request."""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
yield request
|
||||
|
||||
|
||||
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
|
||||
"""The demo bearer token as an ``httpx.Auth``.
|
||||
|
||||
``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying
|
||||
``httpx.AsyncClient`` and the target ``main`` receives is already routed through it.
|
||||
"""
|
||||
return StaticBearerAuth(DEMO_TOKEN)
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
async with Client(target, mode=mode) as client:
|
||||
listed = await client.list_tools()
|
||||
assert [t.name for t in listed.tools] == ["whoami"]
|
||||
|
||||
result = await client.call_tool("whoami", {})
|
||||
assert not result.is_error, result
|
||||
assert result.structured_content == {
|
||||
"subject": "demo-user",
|
||||
"client_id": "demo-client",
|
||||
"scopes": [REQUIRED_SCOPE],
|
||||
}, result.structured_content
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Resource-server-only bearer auth: ``TokenVerifier``/``AuthSettings`` → 401/PRM/principal. Exports ``build_app()``."""
|
||||
|
||||
import time
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
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.mcpserver import MCPServer
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
ISSUER = "https://auth.example.com"
|
||||
RESOURCE_URL = "http://127.0.0.1:8000/mcp"
|
||||
REQUIRED_SCOPE = "mcp:read"
|
||||
DEMO_TOKEN = "demo-token"
|
||||
|
||||
|
||||
class StaticTokenVerifier(TokenVerifier):
|
||||
"""Accepts one hard-coded token. Replace with JWT verification or RFC 7662 introspection."""
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if token != DEMO_TOKEN:
|
||||
return None
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="demo-client",
|
||||
scopes=[REQUIRED_SCOPE],
|
||||
expires_at=int(time.time()) + 3600,
|
||||
subject="demo-user",
|
||||
)
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
mcp = MCPServer(
|
||||
"bearer-auth-example",
|
||||
token_verifier=StaticTokenVerifier(),
|
||||
auth=AuthSettings(
|
||||
issuer_url=AnyHttpUrl(ISSUER),
|
||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||
required_scopes=[REQUIRED_SCOPE],
|
||||
),
|
||||
)
|
||||
|
||||
@mcp.tool(description="Return the authenticated principal.")
|
||||
def whoami() -> dict[str, str | list[str]]:
|
||||
token = get_access_token()
|
||||
assert token is not None # the bearer gate guarantees this on the HTTP path
|
||||
return {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes}
|
||||
|
||||
return mcp.streamable_http_app(transport_security=NO_DNS_REBIND)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Resource-server-only bearer auth (lowlevel API): same gate, hand-built ``CallToolResult``."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
from .server import ISSUER, REQUIRED_SCOPE, RESOURCE_URL, StaticTokenVerifier
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="whoami",
|
||||
description="Return the authenticated principal.",
|
||||
input_schema={"type": "object"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "whoami"
|
||||
token = get_access_token()
|
||||
assert token is not None # the bearer gate guarantees this on the HTTP path
|
||||
payload = {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes}
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(text=f"{token.subject} via {token.client_id}")],
|
||||
structured_content=payload,
|
||||
)
|
||||
|
||||
server = Server("bearer-auth-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
# lowlevel.Server takes auth at app-build time, not in the constructor (cf. MCPServer).
|
||||
return server.streamable_http_app(
|
||||
auth=AuthSettings(
|
||||
issuer_url=AnyHttpUrl(ISSUER),
|
||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||
required_scopes=[REQUIRED_SCOPE],
|
||||
),
|
||||
token_verifier=StaticTokenVerifier(),
|
||||
transport_security=NO_DNS_REBIND,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
Reference in New Issue
Block a user