From 7ebce2a812ed8281e53a43cb89556e45f2367742 Mon Sep 17 00:00:00 2001 From: kinocode3 Date: Sun, 9 Aug 2026 19:37:42 +0900 Subject: [PATCH 1/6] Fix OAuth example resource identifiers --- docs/run/authorization.md | 2 +- .../mcp_simple_auth_client/main.py | 2 +- examples/servers/simple-auth/README.md | 26 ++++- .../simple-auth/mcp_simple_auth/server.py | 30 +++++- examples/snippets/clients/oauth_client.py | 2 +- examples/snippets/servers/oauth_server.py | 2 +- pyproject.toml | 2 + src/mcp/server/auth/settings.py | 5 +- tests/examples/simple_auth/conftest.py | 8 ++ .../simple_auth/test_oauth_resource_url.py | 44 +++++++++ .../simple_auth/test_resource_server_urls.py | 95 +++++++++++++++++++ 11 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 tests/examples/simple_auth/conftest.py create mode 100644 tests/examples/simple_auth/test_oauth_resource_url.py create mode 100644 tests/examples/simple_auth/test_resource_server_urls.py diff --git a/docs/run/authorization.md b/docs/run/authorization.md index b7d731b1e2..5c45c02e31 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -29,7 +29,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl `AuthSettings` is the public face of your resource server: * `issuer_url`: the authorization server that issues your tokens. -* `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. +* `resource_server_url`: the complete public URL of this MCP endpoint, including its path (for example, `/mcp`). It names *which* resource a token is for, and it's where the discovery document lives. Use the externally visible URL when a proxy or mounted application changes the public path; the SDK does not infer it from the internal route. * `required_scopes`: every token must carry all of them. !!! tip diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index a190b89970..14d49746f7 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -214,7 +214,7 @@ async def _default_redirect_handler(authorization_url: str) -> None: # Create OAuth authentication handler using the new interface # Use client_metadata_url to enable CIMD when the server supports it oauth_auth = OAuthClientProvider( - server_url=self.server_url.replace("/mcp", ""), + server_url=self.server_url, client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), storage=InMemoryTokenStorage(), redirect_handler=_default_redirect_handler, diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md index d4a10c43b0..39280c5aa6 100644 --- a/examples/servers/simple-auth/README.md +++ b/examples/servers/simple-auth/README.md @@ -38,6 +38,26 @@ uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --tran ``` +The resource identifier defaults to the selected transport endpoint: `/mcp` for +Streamable HTTP and `/sse` for SSE. If a proxy or mounted application exposes a +different public URL, pass the complete endpoint explicitly: + +```bash +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 \ + --resource-server-url=https://gateway.example.com/services/time/mcp +``` + +Configure the proxy to forward the corresponding public well-known path (for +this example, `/.well-known/oauth-protected-resource/services/time/mcp`) to the +resource-server application as well. + +For SSE, both the transport and protected-resource metadata use `/sse`: + +```bash +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=sse +curl http://localhost:8001/.well-known/oauth-protected-resource/sse +``` + ### Step 3: Test with Client ```bash @@ -53,12 +73,12 @@ MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-c **Client → Resource Server:** ```bash -curl http://localhost:8001/.well-known/oauth-protected-resource +curl http://localhost:8001/.well-known/oauth-protected-resource/mcp ``` ```json { - "resource": "http://localhost:8001", + "resource": "http://localhost:8001/mcp", "authorization_servers": ["http://localhost:9000"] } ``` @@ -119,7 +139,7 @@ This ensures existing MCP servers (which could optionally act as Authorization S ```bash # Test Resource Server discovery endpoint (new architecture) -curl -v http://localhost:8001/.well-known/oauth-protected-resource +curl -v http://localhost:8001/.well-known/oauth-protected-resource/mcp # Test Authorization Server metadata curl -v http://localhost:9000/.well-known/oauth-authorization-server diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 0320871b12..cc67e042f2 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -99,6 +99,11 @@ async def get_time() -> dict[str, Any]: @click.command() @click.option("--port", default=8001, help="Port to listen on") @click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--resource-server-url", + envvar="MCP_RESOURCE_SERVER_URL", + help="Complete public MCP endpoint URL (defaults to the selected transport path)", +) @click.option( "--transport", default="streamable-http", @@ -110,7 +115,13 @@ async def get_time() -> dict[str, Any]: is_flag=True, help="Enable RFC 8707 resource validation", ) -def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int: +def main( + port: int, + auth_server: str, + resource_server_url: str | None, + transport: Literal["sse", "streamable-http"], + oauth_strict: bool, +) -> int: """Run the MCP Resource Server. This server: @@ -128,7 +139,8 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http # Create settings host = "localhost" - server_url = f"http://{host}:{port}/mcp" + transport_path = "/sse" if transport == "sse" else "/mcp" + server_url = resource_server_url or f"http://{host}:{port}{transport_path}" settings = ResourceServerSettings( host=host, port=port, @@ -139,7 +151,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http ) except ValueError as e: logger.error(f"Configuration error: {e}") - logger.error("Make sure to provide a valid Authorization Server URL") + logger.error("Make sure to provide valid Authorization and Resource Server URLs") return 1 try: @@ -148,8 +160,16 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http logger.info(f"🚀 MCP Resource Server running on {settings.server_url}") logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") - # Run the server - this should block and keep running - mcp_server.run(transport=transport, host=host, port=port) + # Keep the advertised resource path and the listening route in lockstep. + if transport == "sse": + mcp_server.run(transport="sse", host=host, port=port, sse_path=transport_path) + else: + mcp_server.run( + transport="streamable-http", + host=host, + port=port, + streamable_http_path=transport_path, + ) logger.info("Server stopped") return 0 except Exception: diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index 58c542ea43..ff253c215d 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -59,7 +59,7 @@ async def handle_callback() -> AuthorizationCodeResult: async def main(): """Run the OAuth client example.""" oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", + server_url="http://localhost:8001/mcp", client_metadata=OAuthClientMetadata( client_name="Example MCP Client", redirect_uris=[AnyUrl("http://localhost:3000/callback")], diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index 962ef0615e..3a0757448f 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -24,7 +24,7 @@ async def verify_token(self, token: str) -> AccessToken | None: # Auth settings for RFC 9728 Protected Resource Metadata auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + resource_server_url=AnyHttpUrl("http://localhost:8000/mcp"), # This server's MCP endpoint required_scopes=["user"], ), ) diff --git a/pyproject.toml b/pyproject.toml index 3c814106d1..7a3a1997cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,6 +175,8 @@ executionEnvironments = [ { root = "tests", extraPaths = [ ".", "examples", + "examples/clients/simple-auth-client", + "examples/servers/simple-auth", ], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "examples/stories", extraPaths = [ "examples", diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index ae2083a38b..2e95a9352f 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -37,6 +37,7 @@ class AuthSettings(BaseModel): # Resource Server settings (when operating as RS only) resource_server_url: AnyHttpUrl | None = Field( ..., - description="The URL of the MCP server to be used as the resource identifier " - "and base route to look up OAuth Protected Resource Metadata.", + description="The complete externally visible URL of the MCP endpoint, including " + "any path prefix and transport path. Used as the resource identifier and to locate " + "OAuth Protected Resource Metadata.", ) diff --git a/tests/examples/simple_auth/conftest.py b/tests/examples/simple_auth/conftest.py new file mode 100644 index 0000000000..9de0ecfe4a --- /dev/null +++ b/tests/examples/simple_auth/conftest.py @@ -0,0 +1,8 @@ +import sys +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).parents[3] +sys.path[:0] = [ + str(REPOSITORY_ROOT / "examples" / "clients" / "simple-auth-client"), + str(REPOSITORY_ROOT / "examples" / "servers" / "simple-auth"), +] diff --git a/tests/examples/simple_auth/test_oauth_resource_url.py b/tests/examples/simple_auth/test_oauth_resource_url.py new file mode 100644 index 0000000000..fa6fc3cd5d --- /dev/null +++ b/tests/examples/simple_auth/test_oauth_resource_url.py @@ -0,0 +1,44 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import pytest +from mcp_simple_auth_client import main as client_module +from mcp_simple_auth_client.main import SimpleAuthClient + +from mcp.client.auth import OAuthClientProvider + +pytestmark = pytest.mark.anyio + + +async def test_the_oauth_provider_receives_the_complete_connection_url(monkeypatch: pytest.MonkeyPatch) -> None: + """The client preserves path prefixes, the transport path, and the query in the resource identifier.""" + resource_url = "https://mcp.example.com/prefix/mcp?tenant=mcp" + providers: list[OAuthClientProvider] = [] + session_calls: list[tuple[Any, Any]] = [] + + class FakeCallbackServer: + def __init__(self, port: int) -> None: + assert port == 3030 + + def start(self) -> None: + pass + + @asynccontextmanager + async def fake_sse_client(**kwargs: Any) -> AsyncIterator[tuple[object, object]]: + assert kwargs["url"] == resource_url + assert isinstance(kwargs["auth"], OAuthClientProvider) + providers.append(kwargs["auth"]) + yield object(), object() + + async def fake_run_session(self: SimpleAuthClient, read_stream: Any, write_stream: Any) -> None: + session_calls.append((read_stream, write_stream)) + + monkeypatch.setattr(client_module, "CallbackServer", FakeCallbackServer) + monkeypatch.setattr(client_module, "sse_client", fake_sse_client) + monkeypatch.setattr(SimpleAuthClient, "_run_session", fake_run_session) + + await SimpleAuthClient(resource_url, transport_type="sse").connect() + + assert [provider.context.server_url for provider in providers] == [resource_url] + assert len(session_calls) == 1 diff --git a/tests/examples/simple_auth/test_resource_server_urls.py b/tests/examples/simple_auth/test_resource_server_urls.py new file mode 100644 index 0000000000..c667d0e084 --- /dev/null +++ b/tests/examples/simple_auth/test_resource_server_urls.py @@ -0,0 +1,95 @@ +from typing import Literal + +import httpx2 +import pytest +from click.testing import CliRunner, Result +from mcp_simple_auth import server + +from mcp.server.mcpserver.server import MCPServer + + +def invoke_resource_server( + monkeypatch: pytest.MonkeyPatch, args: list[str] +) -> tuple[Result, list[MCPServer], list[dict[str, object]]]: + created: list[MCPServer] = [] + run_calls: list[dict[str, object]] = [] + + def fake_run( + self: MCPServer, + transport: Literal["stdio", "sse", "streamable-http"] = "stdio", + *, + host: str = "127.0.0.1", + port: int = 8000, + **kwargs: object, + ) -> None: + created.append(self) + run_calls.append({"transport": transport, "host": host, "port": port, **kwargs}) + + monkeypatch.setattr(MCPServer, "run", fake_run) + return CliRunner().invoke(server.main, args), created, run_calls + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("transport", "expected_path", "method"), + [("streamable-http", "/mcp", "POST"), ("sse", "/sse", "GET")], +) +async def test_selected_transport_determines_the_advertised_resource( + monkeypatch: pytest.MonkeyPatch, + transport: Literal["sse", "streamable-http"], + expected_path: str, + method: str, +) -> None: + """The PRM document and unauthorized response identify the selected public endpoint.""" + result, created, run_calls = invoke_resource_server(monkeypatch, ["--port", "8123", "--transport", transport]) + + assert result.exit_code == 0, result.output + path_argument = "sse_path" if transport == "sse" else "streamable_http_path" + assert run_calls == [{"transport": transport, "host": "localhost", "port": 8123, path_argument: expected_path}] + + resource_url = f"http://localhost:8123{expected_path}" + metadata_path = f"/.well-known/oauth-protected-resource{expected_path}" + app = created[0].sse_app() if transport == "sse" else created[0].streamable_http_app() + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8123") as client: + metadata = await client.get(metadata_path) + unauthorized = await client.request(method, expected_path) + + assert metadata.status_code == 200 + assert metadata.json()["resource"] == resource_url + assert unauthorized.status_code == 401 + assert f'resource_metadata="http://localhost:8123{metadata_path}"' in unauthorized.headers["www-authenticate"] + + +@pytest.mark.anyio +async def test_an_explicit_public_resource_url_is_preserved(monkeypatch: pytest.MonkeyPatch) -> None: + """A reverse-proxy URL is advertised verbatim without internal-route rewriting.""" + public_url = "https://mcp.example.com/services/time/mcp?tenant=alpha" + result, created, run_calls = invoke_resource_server( + monkeypatch, + [ + "--port", + "8123", + "--transport", + "streamable-http", + "--resource-server-url", + public_url, + ], + ) + + assert result.exit_code == 0, result.output + assert run_calls == [ + { + "transport": "streamable-http", + "host": "localhost", + "port": 8123, + "streamable_http_path": "/mcp", + } + ] + + app = created[0].streamable_http_app() + metadata_path = "/.well-known/oauth-protected-resource/services/time/mcp" + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8123") as client: + metadata = await client.get(metadata_path) + + assert metadata.status_code == 200 + assert metadata.json()["resource"] == public_url From ca565c35e1448157d2c8b4454d898791badc46c7 Mon Sep 17 00:00:00 2001 From: kinocode3 Date: Sun, 9 Aug 2026 19:41:30 +0900 Subject: [PATCH 2/6] Remove no-cover pragmas reached by auth tests --- src/mcp/server/mcpserver/server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index bc79c44a36..00d1209d89 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1122,7 +1122,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no required_scopes: list[str] = [] # Set up auth if configured - if self.settings.auth: # pragma: no cover + if self.settings.auth: required_scopes = self.settings.auth.required_scopes or [] # Add auth middleware if token verifier is available @@ -1154,7 +1154,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no ) # When auth is configured, require authentication - if self._token_verifier: # pragma: no cover + if self._token_verifier: # Determine resource metadata URL resource_metadata_url = None if self.settings.auth and self.settings.auth.resource_server_url: @@ -1198,7 +1198,7 @@ async def sse_endpoint(request: Request) -> Response: # pragma: no cover ) ) # Add protected resource metadata endpoint if configured as RS - if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover + if self.settings.auth and self.settings.auth.resource_server_url: from mcp.server.auth.routes import create_protected_resource_routes routes.extend( From a233430c56fb5534157ddabd5b3c599d7c521d2a Mon Sep 17 00:00:00 2001 From: kinocode3 Date: Sun, 9 Aug 2026 19:48:50 +0900 Subject: [PATCH 3/6] Cover legacy auth app configuration --- src/mcp/server/mcpserver/server.py | 27 ++++++++--------- .../simple_auth/test_resource_server_urls.py | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 00d1209d89..6265466fe6 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1124,19 +1124,18 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no # Set up auth if configured if self.settings.auth: required_scopes = self.settings.auth.required_scopes or [] - - # Add auth middleware if token verifier is available - if self._token_verifier: - middleware = [ - # extract auth info from request (but do not require it) - Middleware( - AuthenticationMiddleware, - backend=BearerAuthBackend(self._token_verifier), - ), - # Add the auth context middleware to store - # authenticated user in a contextvar - Middleware(AuthContextMiddleware), - ] + assert self._token_verifier is not None + + middleware = [ + # extract auth info from request (but do not require it) + Middleware( + AuthenticationMiddleware, + backend=BearerAuthBackend(self._token_verifier), + ), + # Add the auth context middleware to store + # authenticated user in a contextvar + Middleware(AuthContextMiddleware), + ] # Add auth endpoints if auth server provider is configured if self._auth_server_provider: @@ -1154,7 +1153,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no ) # When auth is configured, require authentication - if self._token_verifier: + if self.settings.auth: # Determine resource metadata URL resource_metadata_url = None if self.settings.auth and self.settings.auth.resource_server_url: diff --git a/tests/examples/simple_auth/test_resource_server_urls.py b/tests/examples/simple_auth/test_resource_server_urls.py index c667d0e084..9ee0ca857f 100644 --- a/tests/examples/simple_auth/test_resource_server_urls.py +++ b/tests/examples/simple_auth/test_resource_server_urls.py @@ -4,6 +4,9 @@ import pytest from click.testing import CliRunner, Result from mcp_simple_auth import server +from mcp_simple_auth.legacy_as_server import ServerSettings, create_simple_mcp_server +from mcp_simple_auth.simple_auth_provider import SimpleAuthSettings +from pydantic import AnyHttpUrl from mcp.server.mcpserver.server import MCPServer @@ -93,3 +96,30 @@ async def test_an_explicit_public_resource_url_is_preserved(monkeypatch: pytest. assert metadata.status_code == 200 assert metadata.json()["resource"] == public_url + + +@pytest.mark.anyio +async def test_legacy_authorization_server_omits_protected_resource_metadata() -> None: + """Legacy AS mode authenticates SSE without claiming a separate protected-resource URL.""" + server_settings = ServerSettings( + host="localhost", + port=8000, + server_url=AnyHttpUrl("http://localhost:8000"), + auth_callback_path="http://localhost:8000/login/callback", + ) + auth_settings = SimpleAuthSettings( + demo_username="demo_user", + demo_password="demo_password", + mcp_scope="user", + ) + app = create_simple_mcp_server(server_settings, auth_settings).sse_app() + + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8000") as client: + authorization_metadata = await client.get("/.well-known/oauth-authorization-server") + protected_resource_metadata = await client.get("/.well-known/oauth-protected-resource") + unauthorized = await client.get("/sse") + + assert authorization_metadata.status_code == 200 + assert protected_resource_metadata.status_code == 404 + assert unauthorized.status_code == 401 + assert "resource_metadata" not in unauthorized.headers["www-authenticate"] From b82d8548a88b9fea3178bf62bcfa2947aba9c5d7 Mon Sep 17 00:00:00 2001 From: kinocode3 Date: Sun, 9 Aug 2026 22:00:40 +0900 Subject: [PATCH 4/6] Narrow OAuth resource URL fix --- .../tests/test_oauth_resource_url.py | 57 ++++++++ examples/servers/simple-auth/README.md | 14 +- .../simple-auth/mcp_simple_auth/server.py | 10 +- .../tests/test_resource_server_urls.py | 42 ++++++ examples/snippets/servers/oauth_server.py | 2 +- pyproject.toml | 5 +- src/mcp/server/mcpserver/server.py | 31 ++--- tests/examples/simple_auth/conftest.py | 8 -- .../simple_auth/test_oauth_resource_url.py | 44 ------ .../simple_auth/test_resource_server_urls.py | 125 ------------------ 10 files changed, 123 insertions(+), 215 deletions(-) create mode 100644 examples/clients/simple-auth-client/tests/test_oauth_resource_url.py create mode 100644 examples/servers/simple-auth/tests/test_resource_server_urls.py delete mode 100644 tests/examples/simple_auth/conftest.py delete mode 100644 tests/examples/simple_auth/test_oauth_resource_url.py delete mode 100644 tests/examples/simple_auth/test_resource_server_urls.py diff --git a/examples/clients/simple-auth-client/tests/test_oauth_resource_url.py b/examples/clients/simple-auth-client/tests/test_oauth_resource_url.py new file mode 100644 index 0000000000..8aa5b5d0c3 --- /dev/null +++ b/examples/clients/simple-auth-client/tests/test_oauth_resource_url.py @@ -0,0 +1,57 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import anyio +import pytest +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp.client.auth import OAuthClientProvider +from mcp.shared.message import SessionMessage + +from mcp_simple_auth_client import main as client_module +from mcp_simple_auth_client.main import SimpleAuthClient + + +@pytest.mark.anyio +async def test_oauth_client_preserves_the_complete_connection_url(monkeypatch: pytest.MonkeyPatch) -> None: + """The example passes the opaque MCP endpoint unchanged to its OAuth provider.""" + resource_url = "https://mcp.example.com/prefix/mcp?tenant=mcp" + providers: list[OAuthClientProvider] = [] + sessions = 0 + + class FakeCallbackServer: + def __init__(self, port: int) -> None: + assert port == 3030 + + def start(self) -> None: + pass + + @asynccontextmanager + async def fake_sse_client( + *, url: str, auth: OAuthClientProvider, timeout: float + ) -> AsyncIterator[ + tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] + ]: + assert url == resource_url + assert timeout == 60.0 + providers.append(auth) + read_send, read_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + write_send, write_receive = anyio.create_memory_object_stream[SessionMessage](1) + async with read_send, read_receive, write_send, write_receive: + yield read_receive, write_send + + async def record_session( + self: SimpleAuthClient, + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], + ) -> None: + nonlocal sessions + sessions += 1 + + monkeypatch.setattr(client_module, "CallbackServer", FakeCallbackServer) + monkeypatch.setattr(client_module, "sse_client", fake_sse_client) + monkeypatch.setattr(SimpleAuthClient, "_run_session", record_session) + + await SimpleAuthClient(resource_url, transport_type="sse").connect() + + assert sessions == 1 + assert [str(provider.context.server_url) for provider in providers] == [resource_url] diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md index 39280c5aa6..8c42a587be 100644 --- a/examples/servers/simple-auth/README.md +++ b/examples/servers/simple-auth/README.md @@ -38,18 +38,8 @@ uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --tran ``` -The resource identifier defaults to the selected transport endpoint: `/mcp` for -Streamable HTTP and `/sse` for SSE. If a proxy or mounted application exposes a -different public URL, pass the complete endpoint explicitly: - -```bash -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 \ - --resource-server-url=https://gateway.example.com/services/time/mcp -``` - -Configure the proxy to forward the corresponding public well-known path (for -this example, `/.well-known/oauth-protected-resource/services/time/mcp`) to the -resource-server application as well. +The resource identifier follows the selected transport endpoint: `/mcp` for +Streamable HTTP and `/sse` for SSE. For SSE, both the transport and protected-resource metadata use `/sse`: diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index cc67e042f2..44409c33b8 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -99,11 +99,6 @@ async def get_time() -> dict[str, Any]: @click.command() @click.option("--port", default=8001, help="Port to listen on") @click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--resource-server-url", - envvar="MCP_RESOURCE_SERVER_URL", - help="Complete public MCP endpoint URL (defaults to the selected transport path)", -) @click.option( "--transport", default="streamable-http", @@ -118,7 +113,6 @@ async def get_time() -> dict[str, Any]: def main( port: int, auth_server: str, - resource_server_url: str | None, transport: Literal["sse", "streamable-http"], oauth_strict: bool, ) -> int: @@ -140,7 +134,7 @@ def main( # Create settings host = "localhost" transport_path = "/sse" if transport == "sse" else "/mcp" - server_url = resource_server_url or f"http://{host}:{port}{transport_path}" + server_url = f"http://{host}:{port}{transport_path}" settings = ResourceServerSettings( host=host, port=port, @@ -151,7 +145,7 @@ def main( ) except ValueError as e: logger.error(f"Configuration error: {e}") - logger.error("Make sure to provide valid Authorization and Resource Server URLs") + logger.error("Make sure to provide a valid Authorization Server URL") return 1 try: diff --git a/examples/servers/simple-auth/tests/test_resource_server_urls.py b/examples/servers/simple-auth/tests/test_resource_server_urls.py new file mode 100644 index 0000000000..44f3441239 --- /dev/null +++ b/examples/servers/simple-auth/tests/test_resource_server_urls.py @@ -0,0 +1,42 @@ +from typing import Literal + +import pytest +from click.testing import CliRunner +from mcp_simple_auth import server + +from mcp.server.mcpserver.server import MCPServer + + +@pytest.mark.parametrize( + ("transport", "endpoint"), + [("streamable-http", "/mcp"), ("sse", "/sse")], +) +def test_selected_transport_uses_one_resource_path( + monkeypatch: pytest.MonkeyPatch, + transport: Literal["sse", "streamable-http"], + endpoint: str, +) -> None: + """The example advertises and serves the selected transport path.""" + created: list[MCPServer] = [] + run_arguments: list[dict[str, object]] = [] + + def record_run( + self: MCPServer, + transport: Literal["stdio", "sse", "streamable-http"] = "stdio", + *, + host: str = "127.0.0.1", + port: int = 8000, + **kwargs: object, + ) -> None: + created.append(self) + run_arguments.append({"transport": transport, "host": host, "port": port, **kwargs}) + + monkeypatch.setattr(MCPServer, "run", record_run) + result = CliRunner().invoke(server.main, ["--port", "8123", "--transport", transport]) + + assert result.exit_code == 0, result.output + auth = created[0].settings.auth + assert auth is not None + assert str(auth.resource_server_url) == f"http://localhost:8123{endpoint}" + path_argument = "sse_path" if transport == "sse" else "streamable_http_path" + assert run_arguments == [{"transport": transport, "host": "localhost", "port": 8123, path_argument: endpoint}] diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index 3a0757448f..8a77415287 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -24,7 +24,7 @@ async def verify_token(self, token: str) -> AccessToken | None: # Auth settings for RFC 9728 Protected Resource Metadata auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:8000/mcp"), # This server's MCP endpoint + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's MCP endpoint required_scopes=["user"], ), ) diff --git a/pyproject.toml b/pyproject.toml index 7a3a1997cd..a4bc1ac266 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,9 +175,10 @@ executionEnvironments = [ { root = "tests", extraPaths = [ ".", "examples", - "examples/clients/simple-auth-client", - "examples/servers/simple-auth", ], reportUnusedFunction = false, reportPrivateUsage = false }, + { root = "examples/clients/simple-auth-client", extraPaths = [ + "examples/clients/simple-auth-client", + ], reportUnusedFunction = false }, { root = "examples/stories", extraPaths = [ "examples", ], reportUnusedFunction = false }, diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 6265466fe6..bc79c44a36 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1122,20 +1122,21 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no required_scopes: list[str] = [] # Set up auth if configured - if self.settings.auth: + if self.settings.auth: # pragma: no cover required_scopes = self.settings.auth.required_scopes or [] - assert self._token_verifier is not None - - middleware = [ - # extract auth info from request (but do not require it) - Middleware( - AuthenticationMiddleware, - backend=BearerAuthBackend(self._token_verifier), - ), - # Add the auth context middleware to store - # authenticated user in a contextvar - Middleware(AuthContextMiddleware), - ] + + # Add auth middleware if token verifier is available + if self._token_verifier: + middleware = [ + # extract auth info from request (but do not require it) + Middleware( + AuthenticationMiddleware, + backend=BearerAuthBackend(self._token_verifier), + ), + # Add the auth context middleware to store + # authenticated user in a contextvar + Middleware(AuthContextMiddleware), + ] # Add auth endpoints if auth server provider is configured if self._auth_server_provider: @@ -1153,7 +1154,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no ) # When auth is configured, require authentication - if self.settings.auth: + if self._token_verifier: # pragma: no cover # Determine resource metadata URL resource_metadata_url = None if self.settings.auth and self.settings.auth.resource_server_url: @@ -1197,7 +1198,7 @@ async def sse_endpoint(request: Request) -> Response: # pragma: no cover ) ) # Add protected resource metadata endpoint if configured as RS - if self.settings.auth and self.settings.auth.resource_server_url: + if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover from mcp.server.auth.routes import create_protected_resource_routes routes.extend( diff --git a/tests/examples/simple_auth/conftest.py b/tests/examples/simple_auth/conftest.py deleted file mode 100644 index 9de0ecfe4a..0000000000 --- a/tests/examples/simple_auth/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys -from pathlib import Path - -REPOSITORY_ROOT = Path(__file__).parents[3] -sys.path[:0] = [ - str(REPOSITORY_ROOT / "examples" / "clients" / "simple-auth-client"), - str(REPOSITORY_ROOT / "examples" / "servers" / "simple-auth"), -] diff --git a/tests/examples/simple_auth/test_oauth_resource_url.py b/tests/examples/simple_auth/test_oauth_resource_url.py deleted file mode 100644 index fa6fc3cd5d..0000000000 --- a/tests/examples/simple_auth/test_oauth_resource_url.py +++ /dev/null @@ -1,44 +0,0 @@ -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any - -import pytest -from mcp_simple_auth_client import main as client_module -from mcp_simple_auth_client.main import SimpleAuthClient - -from mcp.client.auth import OAuthClientProvider - -pytestmark = pytest.mark.anyio - - -async def test_the_oauth_provider_receives_the_complete_connection_url(monkeypatch: pytest.MonkeyPatch) -> None: - """The client preserves path prefixes, the transport path, and the query in the resource identifier.""" - resource_url = "https://mcp.example.com/prefix/mcp?tenant=mcp" - providers: list[OAuthClientProvider] = [] - session_calls: list[tuple[Any, Any]] = [] - - class FakeCallbackServer: - def __init__(self, port: int) -> None: - assert port == 3030 - - def start(self) -> None: - pass - - @asynccontextmanager - async def fake_sse_client(**kwargs: Any) -> AsyncIterator[tuple[object, object]]: - assert kwargs["url"] == resource_url - assert isinstance(kwargs["auth"], OAuthClientProvider) - providers.append(kwargs["auth"]) - yield object(), object() - - async def fake_run_session(self: SimpleAuthClient, read_stream: Any, write_stream: Any) -> None: - session_calls.append((read_stream, write_stream)) - - monkeypatch.setattr(client_module, "CallbackServer", FakeCallbackServer) - monkeypatch.setattr(client_module, "sse_client", fake_sse_client) - monkeypatch.setattr(SimpleAuthClient, "_run_session", fake_run_session) - - await SimpleAuthClient(resource_url, transport_type="sse").connect() - - assert [provider.context.server_url for provider in providers] == [resource_url] - assert len(session_calls) == 1 diff --git a/tests/examples/simple_auth/test_resource_server_urls.py b/tests/examples/simple_auth/test_resource_server_urls.py deleted file mode 100644 index 9ee0ca857f..0000000000 --- a/tests/examples/simple_auth/test_resource_server_urls.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import Literal - -import httpx2 -import pytest -from click.testing import CliRunner, Result -from mcp_simple_auth import server -from mcp_simple_auth.legacy_as_server import ServerSettings, create_simple_mcp_server -from mcp_simple_auth.simple_auth_provider import SimpleAuthSettings -from pydantic import AnyHttpUrl - -from mcp.server.mcpserver.server import MCPServer - - -def invoke_resource_server( - monkeypatch: pytest.MonkeyPatch, args: list[str] -) -> tuple[Result, list[MCPServer], list[dict[str, object]]]: - created: list[MCPServer] = [] - run_calls: list[dict[str, object]] = [] - - def fake_run( - self: MCPServer, - transport: Literal["stdio", "sse", "streamable-http"] = "stdio", - *, - host: str = "127.0.0.1", - port: int = 8000, - **kwargs: object, - ) -> None: - created.append(self) - run_calls.append({"transport": transport, "host": host, "port": port, **kwargs}) - - monkeypatch.setattr(MCPServer, "run", fake_run) - return CliRunner().invoke(server.main, args), created, run_calls - - -@pytest.mark.anyio -@pytest.mark.parametrize( - ("transport", "expected_path", "method"), - [("streamable-http", "/mcp", "POST"), ("sse", "/sse", "GET")], -) -async def test_selected_transport_determines_the_advertised_resource( - monkeypatch: pytest.MonkeyPatch, - transport: Literal["sse", "streamable-http"], - expected_path: str, - method: str, -) -> None: - """The PRM document and unauthorized response identify the selected public endpoint.""" - result, created, run_calls = invoke_resource_server(monkeypatch, ["--port", "8123", "--transport", transport]) - - assert result.exit_code == 0, result.output - path_argument = "sse_path" if transport == "sse" else "streamable_http_path" - assert run_calls == [{"transport": transport, "host": "localhost", "port": 8123, path_argument: expected_path}] - - resource_url = f"http://localhost:8123{expected_path}" - metadata_path = f"/.well-known/oauth-protected-resource{expected_path}" - app = created[0].sse_app() if transport == "sse" else created[0].streamable_http_app() - async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8123") as client: - metadata = await client.get(metadata_path) - unauthorized = await client.request(method, expected_path) - - assert metadata.status_code == 200 - assert metadata.json()["resource"] == resource_url - assert unauthorized.status_code == 401 - assert f'resource_metadata="http://localhost:8123{metadata_path}"' in unauthorized.headers["www-authenticate"] - - -@pytest.mark.anyio -async def test_an_explicit_public_resource_url_is_preserved(monkeypatch: pytest.MonkeyPatch) -> None: - """A reverse-proxy URL is advertised verbatim without internal-route rewriting.""" - public_url = "https://mcp.example.com/services/time/mcp?tenant=alpha" - result, created, run_calls = invoke_resource_server( - monkeypatch, - [ - "--port", - "8123", - "--transport", - "streamable-http", - "--resource-server-url", - public_url, - ], - ) - - assert result.exit_code == 0, result.output - assert run_calls == [ - { - "transport": "streamable-http", - "host": "localhost", - "port": 8123, - "streamable_http_path": "/mcp", - } - ] - - app = created[0].streamable_http_app() - metadata_path = "/.well-known/oauth-protected-resource/services/time/mcp" - async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8123") as client: - metadata = await client.get(metadata_path) - - assert metadata.status_code == 200 - assert metadata.json()["resource"] == public_url - - -@pytest.mark.anyio -async def test_legacy_authorization_server_omits_protected_resource_metadata() -> None: - """Legacy AS mode authenticates SSE without claiming a separate protected-resource URL.""" - server_settings = ServerSettings( - host="localhost", - port=8000, - server_url=AnyHttpUrl("http://localhost:8000"), - auth_callback_path="http://localhost:8000/login/callback", - ) - auth_settings = SimpleAuthSettings( - demo_username="demo_user", - demo_password="demo_password", - mcp_scope="user", - ) - app = create_simple_mcp_server(server_settings, auth_settings).sse_app() - - async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8000") as client: - authorization_metadata = await client.get("/.well-known/oauth-authorization-server") - protected_resource_metadata = await client.get("/.well-known/oauth-protected-resource") - unauthorized = await client.get("/sse") - - assert authorization_metadata.status_code == 200 - assert protected_resource_metadata.status_code == 404 - assert unauthorized.status_code == 401 - assert "resource_metadata" not in unauthorized.headers["www-authenticate"] From 1c0c458f26764c9f1dff87ec7c8520efcbeb96df Mon Sep 17 00:00:00 2001 From: kinocode3 Date: Sun, 9 Aug 2026 22:11:32 +0900 Subject: [PATCH 5/6] Fix OAuth example test collection --- tests/examples/simple_auth/conftest.py | 23 ++++++++++++ .../simple_auth}/test_oauth_resource_url.py | 36 +++++++++++++++---- .../simple_auth}/test_resource_server_urls.py | 17 +++++++-- 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 tests/examples/simple_auth/conftest.py rename {examples/clients/simple-auth-client/tests => tests/examples/simple_auth}/test_oauth_resource_url.py (65%) rename {examples/servers/simple-auth/tests => tests/examples/simple_auth}/test_resource_server_urls.py (74%) diff --git a/tests/examples/simple_auth/conftest.py b/tests/examples/simple_auth/conftest.py new file mode 100644 index 0000000000..1d6da171af --- /dev/null +++ b/tests/examples/simple_auth/conftest.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import importlib +import sys +from collections.abc import Callable +from pathlib import Path +from types import ModuleType + +import pytest + +@pytest.fixture +def load_example_module() -> Callable[[Path, str], ModuleType]: + """Import a workspace example without requiring it in the root test environment.""" + + def load(package_root: Path, module_name: str) -> ModuleType: + original_path = sys.path.copy() + try: + sys.path.insert(0, str(package_root)) + return importlib.import_module(module_name) + finally: + sys.path[:] = original_path + + return load diff --git a/examples/clients/simple-auth-client/tests/test_oauth_resource_url.py b/tests/examples/simple_auth/test_oauth_resource_url.py similarity index 65% rename from examples/clients/simple-auth-client/tests/test_oauth_resource_url.py rename to tests/examples/simple_auth/test_oauth_resource_url.py index 8aa5b5d0c3..0f9ee18fcb 100644 --- a/examples/clients/simple-auth-client/tests/test_oauth_resource_url.py +++ b/tests/examples/simple_auth/test_oauth_resource_url.py @@ -1,19 +1,43 @@ -from collections.abc import AsyncIterator +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager +from pathlib import Path +from types import ModuleType +from typing import Protocol, cast import anyio import pytest from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + from mcp.client.auth import OAuthClientProvider from mcp.shared.message import SessionMessage -from mcp_simple_auth_client import main as client_module -from mcp_simple_auth_client.main import SimpleAuthClient +CLIENT_ROOT = Path(__file__).parents[3] / "examples" / "clients" / "simple-auth-client" + + +class SimpleAuthClient(Protocol): + def __init__( + self, + server_url: str, + transport_type: str = "streamable-http", + client_metadata_url: str | None = None, + ) -> None: ... + + async def connect(self) -> None: ... + + +class ClientModule(Protocol): + SimpleAuthClient: type[SimpleAuthClient] @pytest.mark.anyio -async def test_oauth_client_preserves_the_complete_connection_url(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_oauth_client_preserves_the_complete_connection_url( + monkeypatch: pytest.MonkeyPatch, + load_example_module: Callable[[Path, str], ModuleType], +) -> None: """The example passes the opaque MCP endpoint unchanged to its OAuth provider.""" + client_module = cast(ClientModule, load_example_module(CLIENT_ROOT, "mcp_simple_auth_client.main")) resource_url = "https://mcp.example.com/prefix/mcp?tenant=mcp" providers: list[OAuthClientProvider] = [] sessions = 0 @@ -49,9 +73,9 @@ async def record_session( monkeypatch.setattr(client_module, "CallbackServer", FakeCallbackServer) monkeypatch.setattr(client_module, "sse_client", fake_sse_client) - monkeypatch.setattr(SimpleAuthClient, "_run_session", record_session) + monkeypatch.setattr(client_module.SimpleAuthClient, "_run_session", record_session) - await SimpleAuthClient(resource_url, transport_type="sse").connect() + await client_module.SimpleAuthClient(resource_url, transport_type="sse").connect() assert sessions == 1 assert [str(provider.context.server_url) for provider in providers] == [resource_url] diff --git a/examples/servers/simple-auth/tests/test_resource_server_urls.py b/tests/examples/simple_auth/test_resource_server_urls.py similarity index 74% rename from examples/servers/simple-auth/tests/test_resource_server_urls.py rename to tests/examples/simple_auth/test_resource_server_urls.py index 44f3441239..4c8fe66ad3 100644 --- a/examples/servers/simple-auth/tests/test_resource_server_urls.py +++ b/tests/examples/simple_auth/test_resource_server_urls.py @@ -1,11 +1,22 @@ -from typing import Literal +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from types import ModuleType +from typing import Literal, Protocol, cast import pytest +from click import Command from click.testing import CliRunner -from mcp_simple_auth import server from mcp.server.mcpserver.server import MCPServer +SERVER_ROOT = Path(__file__).parents[3] / "examples" / "servers" / "simple-auth" + + +class ServerModule(Protocol): + main: Command + @pytest.mark.parametrize( ("transport", "endpoint"), @@ -13,10 +24,12 @@ ) def test_selected_transport_uses_one_resource_path( monkeypatch: pytest.MonkeyPatch, + load_example_module: Callable[[Path, str], ModuleType], transport: Literal["sse", "streamable-http"], endpoint: str, ) -> None: """The example advertises and serves the selected transport path.""" + server = cast(ServerModule, load_example_module(SERVER_ROOT, "mcp_simple_auth.server")) created: list[MCPServer] = [] run_arguments: list[dict[str, object]] = [] From 2806e5c8466113ff21d9e42e491be62567a5aa47 Mon Sep 17 00:00:00 2001 From: kinocode3 Date: Sun, 9 Aug 2026 22:14:51 +0900 Subject: [PATCH 6/6] Format OAuth test fixture --- tests/examples/simple_auth/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/examples/simple_auth/conftest.py b/tests/examples/simple_auth/conftest.py index 1d6da171af..04bdf22120 100644 --- a/tests/examples/simple_auth/conftest.py +++ b/tests/examples/simple_auth/conftest.py @@ -8,6 +8,7 @@ import pytest + @pytest.fixture def load_example_module() -> Callable[[Path, str], ModuleType]: """Import a workspace example without requiring it in the root test environment."""