Skip to content

Commit b82d854

Browse files
committed
Narrow OAuth resource URL fix
1 parent a233430 commit b82d854

10 files changed

Lines changed: 123 additions & 215 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from collections.abc import AsyncIterator
2+
from contextlib import asynccontextmanager
3+
4+
import anyio
5+
import pytest
6+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
7+
from mcp.client.auth import OAuthClientProvider
8+
from mcp.shared.message import SessionMessage
9+
10+
from mcp_simple_auth_client import main as client_module
11+
from mcp_simple_auth_client.main import SimpleAuthClient
12+
13+
14+
@pytest.mark.anyio
15+
async def test_oauth_client_preserves_the_complete_connection_url(monkeypatch: pytest.MonkeyPatch) -> None:
16+
"""The example passes the opaque MCP endpoint unchanged to its OAuth provider."""
17+
resource_url = "https://mcp.example.com/prefix/mcp?tenant=mcp"
18+
providers: list[OAuthClientProvider] = []
19+
sessions = 0
20+
21+
class FakeCallbackServer:
22+
def __init__(self, port: int) -> None:
23+
assert port == 3030
24+
25+
def start(self) -> None:
26+
pass
27+
28+
@asynccontextmanager
29+
async def fake_sse_client(
30+
*, url: str, auth: OAuthClientProvider, timeout: float
31+
) -> AsyncIterator[
32+
tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
33+
]:
34+
assert url == resource_url
35+
assert timeout == 60.0
36+
providers.append(auth)
37+
read_send, read_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1)
38+
write_send, write_receive = anyio.create_memory_object_stream[SessionMessage](1)
39+
async with read_send, read_receive, write_send, write_receive:
40+
yield read_receive, write_send
41+
42+
async def record_session(
43+
self: SimpleAuthClient,
44+
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
45+
write_stream: MemoryObjectSendStream[SessionMessage],
46+
) -> None:
47+
nonlocal sessions
48+
sessions += 1
49+
50+
monkeypatch.setattr(client_module, "CallbackServer", FakeCallbackServer)
51+
monkeypatch.setattr(client_module, "sse_client", fake_sse_client)
52+
monkeypatch.setattr(SimpleAuthClient, "_run_session", record_session)
53+
54+
await SimpleAuthClient(resource_url, transport_type="sse").connect()
55+
56+
assert sessions == 1
57+
assert [str(provider.context.server_url) for provider in providers] == [resource_url]

examples/servers/simple-auth/README.md

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,8 @@ uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --tran
3838

3939
```
4040

41-
The resource identifier defaults to the selected transport endpoint: `/mcp` for
42-
Streamable HTTP and `/sse` for SSE. If a proxy or mounted application exposes a
43-
different public URL, pass the complete endpoint explicitly:
44-
45-
```bash
46-
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 \
47-
--resource-server-url=https://gateway.example.com/services/time/mcp
48-
```
49-
50-
Configure the proxy to forward the corresponding public well-known path (for
51-
this example, `/.well-known/oauth-protected-resource/services/time/mcp`) to the
52-
resource-server application as well.
41+
The resource identifier follows the selected transport endpoint: `/mcp` for
42+
Streamable HTTP and `/sse` for SSE.
5343

5444
For SSE, both the transport and protected-resource metadata use `/sse`:
5545

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,6 @@ async def get_time() -> dict[str, Any]:
9999
@click.command()
100100
@click.option("--port", default=8001, help="Port to listen on")
101101
@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL")
102-
@click.option(
103-
"--resource-server-url",
104-
envvar="MCP_RESOURCE_SERVER_URL",
105-
help="Complete public MCP endpoint URL (defaults to the selected transport path)",
106-
)
107102
@click.option(
108103
"--transport",
109104
default="streamable-http",
@@ -118,7 +113,6 @@ async def get_time() -> dict[str, Any]:
118113
def main(
119114
port: int,
120115
auth_server: str,
121-
resource_server_url: str | None,
122116
transport: Literal["sse", "streamable-http"],
123117
oauth_strict: bool,
124118
) -> int:
@@ -140,7 +134,7 @@ def main(
140134
# Create settings
141135
host = "localhost"
142136
transport_path = "/sse" if transport == "sse" else "/mcp"
143-
server_url = resource_server_url or f"http://{host}:{port}{transport_path}"
137+
server_url = f"http://{host}:{port}{transport_path}"
144138
settings = ResourceServerSettings(
145139
host=host,
146140
port=port,
@@ -151,7 +145,7 @@ def main(
151145
)
152146
except ValueError as e:
153147
logger.error(f"Configuration error: {e}")
154-
logger.error("Make sure to provide valid Authorization and Resource Server URLs")
148+
logger.error("Make sure to provide a valid Authorization Server URL")
155149
return 1
156150

157151
try:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Literal
2+
3+
import pytest
4+
from click.testing import CliRunner
5+
from mcp_simple_auth import server
6+
7+
from mcp.server.mcpserver.server import MCPServer
8+
9+
10+
@pytest.mark.parametrize(
11+
("transport", "endpoint"),
12+
[("streamable-http", "/mcp"), ("sse", "/sse")],
13+
)
14+
def test_selected_transport_uses_one_resource_path(
15+
monkeypatch: pytest.MonkeyPatch,
16+
transport: Literal["sse", "streamable-http"],
17+
endpoint: str,
18+
) -> None:
19+
"""The example advertises and serves the selected transport path."""
20+
created: list[MCPServer] = []
21+
run_arguments: list[dict[str, object]] = []
22+
23+
def record_run(
24+
self: MCPServer,
25+
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
26+
*,
27+
host: str = "127.0.0.1",
28+
port: int = 8000,
29+
**kwargs: object,
30+
) -> None:
31+
created.append(self)
32+
run_arguments.append({"transport": transport, "host": host, "port": port, **kwargs})
33+
34+
monkeypatch.setattr(MCPServer, "run", record_run)
35+
result = CliRunner().invoke(server.main, ["--port", "8123", "--transport", transport])
36+
37+
assert result.exit_code == 0, result.output
38+
auth = created[0].settings.auth
39+
assert auth is not None
40+
assert str(auth.resource_server_url) == f"http://localhost:8123{endpoint}"
41+
path_argument = "sse_path" if transport == "sse" else "streamable_http_path"
42+
assert run_arguments == [{"transport": transport, "host": "localhost", "port": 8123, path_argument: endpoint}]

examples/snippets/servers/oauth_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ async def verify_token(self, token: str) -> AccessToken | None:
2424
# Auth settings for RFC 9728 Protected Resource Metadata
2525
auth=AuthSettings(
2626
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
27-
resource_server_url=AnyHttpUrl("http://localhost:8000/mcp"), # This server's MCP endpoint
27+
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's MCP endpoint
2828
required_scopes=["user"],
2929
),
3030
)

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,10 @@ executionEnvironments = [
175175
{ root = "tests", extraPaths = [
176176
".",
177177
"examples",
178-
"examples/clients/simple-auth-client",
179-
"examples/servers/simple-auth",
180178
], reportUnusedFunction = false, reportPrivateUsage = false },
179+
{ root = "examples/clients/simple-auth-client", extraPaths = [
180+
"examples/clients/simple-auth-client",
181+
], reportUnusedFunction = false },
181182
{ root = "examples/stories", extraPaths = [
182183
"examples",
183184
], reportUnusedFunction = false },

src/mcp/server/mcpserver/server.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,20 +1122,21 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
11221122
required_scopes: list[str] = []
11231123

11241124
# Set up auth if configured
1125-
if self.settings.auth:
1125+
if self.settings.auth: # pragma: no cover
11261126
required_scopes = self.settings.auth.required_scopes or []
1127-
assert self._token_verifier is not None
1128-
1129-
middleware = [
1130-
# extract auth info from request (but do not require it)
1131-
Middleware(
1132-
AuthenticationMiddleware,
1133-
backend=BearerAuthBackend(self._token_verifier),
1134-
),
1135-
# Add the auth context middleware to store
1136-
# authenticated user in a contextvar
1137-
Middleware(AuthContextMiddleware),
1138-
]
1127+
1128+
# Add auth middleware if token verifier is available
1129+
if self._token_verifier:
1130+
middleware = [
1131+
# extract auth info from request (but do not require it)
1132+
Middleware(
1133+
AuthenticationMiddleware,
1134+
backend=BearerAuthBackend(self._token_verifier),
1135+
),
1136+
# Add the auth context middleware to store
1137+
# authenticated user in a contextvar
1138+
Middleware(AuthContextMiddleware),
1139+
]
11391140

11401141
# Add auth endpoints if auth server provider is configured
11411142
if self._auth_server_provider:
@@ -1153,7 +1154,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
11531154
)
11541155

11551156
# When auth is configured, require authentication
1156-
if self.settings.auth:
1157+
if self._token_verifier: # pragma: no cover
11571158
# Determine resource metadata URL
11581159
resource_metadata_url = None
11591160
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
11971198
)
11981199
)
11991200
# Add protected resource metadata endpoint if configured as RS
1200-
if self.settings.auth and self.settings.auth.resource_server_url:
1201+
if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover
12011202
from mcp.server.auth.routes import create_protected_resource_routes
12021203

12031204
routes.extend(

tests/examples/simple_auth/conftest.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

tests/examples/simple_auth/test_oauth_resource_url.py

Lines changed: 0 additions & 44 deletions
This file was deleted.

0 commit comments

Comments
 (0)