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) Waiting to run
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) Waiting to run
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# oauth
|
||||
|
||||
The full OAuth 2.1 authorization-code flow against an in-process Authorization
|
||||
Server, over Streamable HTTP. On the **server** side: one `MCPServer(auth=...,
|
||||
auth_server_provider=...)` constructor call co-hosts the RFC 9728
|
||||
protected-resource metadata route, the AS routes (`/register`, `/authorize`,
|
||||
`/token`, `/.well-known/oauth-authorization-server`) and the bearer-gated
|
||||
`/mcp` endpoint on a single Starlette app. On the **client** side:
|
||||
`OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by
|
||||
walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange →
|
||||
bearer retry — all inside the first awaited request, with no user-visible
|
||||
`UnauthorizedError`.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# HTTP — the client self-hosts the co-hosted AS + bearer-gated /mcp, runs the
|
||||
# authorization-code flow (headless: redirect followed in-process), then tears
|
||||
# it down. Self-hosting uses this story's fixed :8000 (the AS metadata pins
|
||||
# it), so :8000 must be free.
|
||||
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http --server server_lowlevel
|
||||
|
||||
# against a server you run yourself (real uvicorn on :8000)
|
||||
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 &
|
||||
SERVER_PID=$!
|
||||
uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp
|
||||
kill "$SERVER_PID"
|
||||
```
|
||||
|
||||
The port must be **8000**: the demo AS metadata (`_shared/auth.py` `BASE_URL`)
|
||||
is pinned to it on both the client and server side, so on any other port the
|
||||
PRM/AS discovery chain points at the wrong origin.
|
||||
|
||||
`OAUTH_DEMO_AUTO_CONSENT=1` makes the demo AS skip the consent screen and 302
|
||||
straight back with `?code=...`; without it the authorize step returns
|
||||
`error=interaction_required` so you can see where a real browser would open.
|
||||
|
||||
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
|
||||
can't carry the flow. Both runners close that gap the same way: `run_client`
|
||||
(above) and the pytest harness build an authed `httpx.AsyncClient` from
|
||||
this module's `build_auth` export and hand `main` targets that are already
|
||||
routed through it.
|
||||
|
||||
## What to look at
|
||||
|
||||
- **`client.py` — `Client(targets(), mode=mode)`, twice.** The target `main`
|
||||
receives is already authed. The first construction is where the whole flow
|
||||
happens: the first request `401`s and `OAuthClientProvider` runs PRM
|
||||
discovery → AS metadata → DCR → PKCE authorize → token exchange → bearer
|
||||
retry before `whoami`'s result reaches the body.
|
||||
- **`client.py` — the second `Client(targets(), mode=mode)`.** A `Client`
|
||||
cannot be re-entered after `__aexit__`; reconnecting means constructing a new
|
||||
one. The provider's `TokenStorage` persisted the tokens and the DCR
|
||||
registration, so this one sends `Authorization: Bearer ...` on its very first
|
||||
request — no second `/authorize`, no second `/register`. The demo AS mints a
|
||||
fresh `client_id` per DCR call, so `whoami` returning the *same* `client_id`
|
||||
is the reuse proof.
|
||||
- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx.Auth`.
|
||||
`Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands
|
||||
the auth has to be threaded onto the underlying `httpx.AsyncClient` by hand.
|
||||
- **`server.py` — `MCPServer(auth=..., auth_server_provider=...)`.** The
|
||||
constructor wires everything; `streamable_http_app()` reads it back. (Don't
|
||||
also pass `token_verifier=` — `auth_server_provider` and `token_verifier` are
|
||||
mutually exclusive.) The `whoami` tool reads the validated principal via
|
||||
`get_access_token()` — a per-HTTP-request contextvar set by
|
||||
`AuthContextMiddleware`, not per-session.
|
||||
- **`server_lowlevel.py`** — same wire shape, but `lowlevel.Server` takes
|
||||
`auth=`/`token_verifier=`/`auth_server_provider=` on `streamable_http_app()`
|
||||
rather than the constructor. `mcp.server.auth.*` is a helper tier the lowlevel
|
||||
API may import directly.
|
||||
|
||||
## Caveats
|
||||
|
||||
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
|
||||
and the in-process httpx bridge sends no `Origin` header. Drop the kwarg for a
|
||||
real deployment.
|
||||
- `HeadlessOAuth` only works because the demo AS auto-consents; a real
|
||||
`redirect_handler` would open a browser and a real `callback_handler` would
|
||||
run a loopback HTTP listener for the redirect.
|
||||
- The `mcp.server.auth.*` import paths are deep (no `mcp.server` re-export yet).
|
||||
|
||||
## Spec
|
||||
|
||||
[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
|
||||
|
||||
## See also
|
||||
|
||||
`bearer_auth/` (RS-only, static token, no AS) · `oauth_client_credentials/`
|
||||
(M2M `client_credentials` grant — no browser, no DCR) · `reconnect/` (the other
|
||||
multi-connection `targets()` consumer, no auth).
|
||||
@@ -0,0 +1,61 @@
|
||||
"""HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`."""
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.client import Client
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import OAuthClientMetadata
|
||||
from stories._harness import TargetFactory, run_client
|
||||
|
||||
# MCP_URL pins the resource to :8000. The demo AS's own metadata (issuer, PRM `resource`)
|
||||
# is built from the same constant on the server side, so the whole story is bound to that
|
||||
# port — run the server on 8000 or both halves of the discovery chain point at the wrong origin.
|
||||
from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage
|
||||
|
||||
|
||||
def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth:
|
||||
"""An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly.
|
||||
|
||||
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
|
||||
`httpx.AsyncClient` and every target `main` receives is already routed through it.
|
||||
"""
|
||||
headless = HeadlessOAuth()
|
||||
headless.bind(http_client)
|
||||
return OAuthClientProvider(
|
||||
server_url=MCP_URL,
|
||||
client_metadata=OAuthClientMetadata(
|
||||
client_name="oauth-story-client",
|
||||
redirect_uris=[AnyUrl(REDIRECT_URI)],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
),
|
||||
storage=InMemoryTokenStorage(),
|
||||
redirect_handler=headless.redirect_handler,
|
||||
callback_handler=headless.callback_handler,
|
||||
)
|
||||
|
||||
|
||||
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
|
||||
# The target is already authed with build_auth's OAuthClientProvider. The first request to
|
||||
# hit the wire 401s, and the provider walks PRM discovery → AS metadata → DCR → PKCE
|
||||
# authorize → token exchange → bearer retry before any result reaches this body. No
|
||||
# UnauthorizedError ever surfaces.
|
||||
async with Client(targets(), mode=mode) as client:
|
||||
first = await client.call_tool("whoami", {})
|
||||
assert first.structured_content is not None
|
||||
assert "mcp" in first.structured_content["scopes"], first
|
||||
registered_id = first.structured_content["client_id"]
|
||||
|
||||
# A Client cannot be re-entered after __aexit__; reconnecting means constructing a new one.
|
||||
# The provider's TokenStorage persisted both the issued tokens and the DCR registration, so
|
||||
# this connection sends `Authorization: Bearer ...` on its very first request — no second
|
||||
# /authorize, no second /register. The demo AS mints a fresh client_id per DCR call, so the
|
||||
# same principal coming back IS the reuse proof.
|
||||
async with Client(targets(), mode=mode) as reconnected:
|
||||
again = await reconnected.call_tool("whoami", {})
|
||||
assert again.structured_content is not None
|
||||
assert again.structured_content["client_id"] == registered_id, again
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""OAuth-protected MCP server: in-process AS + PRM + bearer-gated /mcp on one Starlette app — exports `build_app()`."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings
|
||||
|
||||
|
||||
class Principal(BaseModel):
|
||||
client_id: str
|
||||
scopes: list[str]
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
# The provider is both the Authorization Server (DCR/authorize/token) and the
|
||||
# token store the bearer middleware validates against — one in-memory dict.
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
|
||||
# ``auth_server_provider=`` alone is enough — MCPServer derives a token verifier
|
||||
# from it (passing both trips the mutex guard).
|
||||
mcp = MCPServer(
|
||||
"oauth-example",
|
||||
auth=auth_settings(required_scopes=["mcp"]),
|
||||
auth_server_provider=provider,
|
||||
)
|
||||
|
||||
@mcp.tool(description="Return the authenticated principal's client_id and granted scopes.")
|
||||
def whoami() -> Principal:
|
||||
token = get_access_token()
|
||||
assert token is not None
|
||||
return Principal(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,58 @@
|
||||
"""OAuth-protected MCP server (lowlevel API): same app shape, hand-built result types."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import ProviderTokenVerifier
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings
|
||||
|
||||
WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"client_id": {"type": "string"}, "scopes": {"type": "array", "items": {"type": "string"}}},
|
||||
"required": ["client_id", "scopes"],
|
||||
}
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
|
||||
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's client_id and granted scopes.",
|
||||
input_schema={"type": "object"},
|
||||
output_schema=WHOAMI_OUTPUT_SCHEMA,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
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
|
||||
payload = {"client_id": token.client_id, "scopes": token.scopes}
|
||||
return types.CallToolResult(content=[types.TextContent(text=token.client_id)], structured_content=payload)
|
||||
|
||||
server = Server("oauth-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
# Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth as
|
||||
# streamable_http_app() kwargs — same wired routes, different entry point.
|
||||
return server.streamable_http_app(
|
||||
auth=auth_settings(required_scopes=["mcp"]),
|
||||
token_verifier=ProviderTokenVerifier(provider),
|
||||
auth_server_provider=provider,
|
||||
transport_security=NO_DNS_REBIND,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
Reference in New Issue
Block a user