Skip to content

Commit 7ebce2a

Browse files
committed
Fix OAuth example resource identifiers
1 parent a4f4ccd commit 7ebce2a

11 files changed

Lines changed: 204 additions & 14 deletions

File tree

docs/run/authorization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl
2929
`AuthSettings` is the public face of your resource server:
3030

3131
* `issuer_url`: the authorization server that issues your tokens.
32-
* `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.
32+
* `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.
3333
* `required_scopes`: every token must carry all of them.
3434

3535
!!! tip

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ async def _default_redirect_handler(authorization_url: str) -> None:
214214
# Create OAuth authentication handler using the new interface
215215
# Use client_metadata_url to enable CIMD when the server supports it
216216
oauth_auth = OAuthClientProvider(
217-
server_url=self.server_url.replace("/mcp", ""),
217+
server_url=self.server_url,
218218
client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict),
219219
storage=InMemoryTokenStorage(),
220220
redirect_handler=_default_redirect_handler,

examples/servers/simple-auth/README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ 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.
53+
54+
For SSE, both the transport and protected-resource metadata use `/sse`:
55+
56+
```bash
57+
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=sse
58+
curl http://localhost:8001/.well-known/oauth-protected-resource/sse
59+
```
60+
4161
### Step 3: Test with Client
4262

4363
```bash
@@ -53,12 +73,12 @@ MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-c
5373
**Client → Resource Server:**
5474

5575
```bash
56-
curl http://localhost:8001/.well-known/oauth-protected-resource
76+
curl http://localhost:8001/.well-known/oauth-protected-resource/mcp
5777
```
5878

5979
```json
6080
{
61-
"resource": "http://localhost:8001",
81+
"resource": "http://localhost:8001/mcp",
6282
"authorization_servers": ["http://localhost:9000"]
6383
}
6484
```
@@ -119,7 +139,7 @@ This ensures existing MCP servers (which could optionally act as Authorization S
119139

120140
```bash
121141
# Test Resource Server discovery endpoint (new architecture)
122-
curl -v http://localhost:8001/.well-known/oauth-protected-resource
142+
curl -v http://localhost:8001/.well-known/oauth-protected-resource/mcp
123143

124144
# Test Authorization Server metadata
125145
curl -v http://localhost:9000/.well-known/oauth-authorization-server

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ 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+
)
102107
@click.option(
103108
"--transport",
104109
default="streamable-http",
@@ -110,7 +115,13 @@ async def get_time() -> dict[str, Any]:
110115
is_flag=True,
111116
help="Enable RFC 8707 resource validation",
112117
)
113-
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int:
118+
def main(
119+
port: int,
120+
auth_server: str,
121+
resource_server_url: str | None,
122+
transport: Literal["sse", "streamable-http"],
123+
oauth_strict: bool,
124+
) -> int:
114125
"""Run the MCP Resource Server.
115126
116127
This server:
@@ -128,7 +139,8 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
128139

129140
# Create settings
130141
host = "localhost"
131-
server_url = f"http://{host}:{port}/mcp"
142+
transport_path = "/sse" if transport == "sse" else "/mcp"
143+
server_url = resource_server_url or f"http://{host}:{port}{transport_path}"
132144
settings = ResourceServerSettings(
133145
host=host,
134146
port=port,
@@ -139,7 +151,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
139151
)
140152
except ValueError as e:
141153
logger.error(f"Configuration error: {e}")
142-
logger.error("Make sure to provide a valid Authorization Server URL")
154+
logger.error("Make sure to provide valid Authorization and Resource Server URLs")
143155
return 1
144156

145157
try:
@@ -148,8 +160,16 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
148160
logger.info(f"🚀 MCP Resource Server running on {settings.server_url}")
149161
logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}")
150162

151-
# Run the server - this should block and keep running
152-
mcp_server.run(transport=transport, host=host, port=port)
163+
# Keep the advertised resource path and the listening route in lockstep.
164+
if transport == "sse":
165+
mcp_server.run(transport="sse", host=host, port=port, sse_path=transport_path)
166+
else:
167+
mcp_server.run(
168+
transport="streamable-http",
169+
host=host,
170+
port=port,
171+
streamable_http_path=transport_path,
172+
)
153173
logger.info("Server stopped")
154174
return 0
155175
except Exception:

examples/snippets/clients/oauth_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def handle_callback() -> AuthorizationCodeResult:
5959
async def main():
6060
"""Run the OAuth client example."""
6161
oauth_auth = OAuthClientProvider(
62-
server_url="http://localhost:8001",
62+
server_url="http://localhost:8001/mcp",
6363
client_metadata=OAuthClientMetadata(
6464
client_name="Example MCP Client",
6565
redirect_uris=[AnyUrl("http://localhost:3000/callback")],

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:3001"), # This server's URL
27+
resource_server_url=AnyHttpUrl("http://localhost:8000/mcp"), # This server's MCP endpoint
2828
required_scopes=["user"],
2929
),
3030
)

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ executionEnvironments = [
175175
{ root = "tests", extraPaths = [
176176
".",
177177
"examples",
178+
"examples/clients/simple-auth-client",
179+
"examples/servers/simple-auth",
178180
], reportUnusedFunction = false, reportPrivateUsage = false },
179181
{ root = "examples/stories", extraPaths = [
180182
"examples",

src/mcp/server/auth/settings.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class AuthSettings(BaseModel):
3737
# Resource Server settings (when operating as RS only)
3838
resource_server_url: AnyHttpUrl | None = Field(
3939
...,
40-
description="The URL of the MCP server to be used as the resource identifier "
41-
"and base route to look up OAuth Protected Resource Metadata.",
40+
description="The complete externally visible URL of the MCP endpoint, including "
41+
"any path prefix and transport path. Used as the resource identifier and to locate "
42+
"OAuth Protected Resource Metadata.",
4243
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
from pathlib import Path
3+
4+
REPOSITORY_ROOT = Path(__file__).parents[3]
5+
sys.path[:0] = [
6+
str(REPOSITORY_ROOT / "examples" / "clients" / "simple-auth-client"),
7+
str(REPOSITORY_ROOT / "examples" / "servers" / "simple-auth"),
8+
]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from collections.abc import AsyncIterator
2+
from contextlib import asynccontextmanager
3+
from typing import Any
4+
5+
import pytest
6+
from mcp_simple_auth_client import main as client_module
7+
from mcp_simple_auth_client.main import SimpleAuthClient
8+
9+
from mcp.client.auth import OAuthClientProvider
10+
11+
pytestmark = pytest.mark.anyio
12+
13+
14+
async def test_the_oauth_provider_receives_the_complete_connection_url(monkeypatch: pytest.MonkeyPatch) -> None:
15+
"""The client preserves path prefixes, the transport path, and the query in the resource identifier."""
16+
resource_url = "https://mcp.example.com/prefix/mcp?tenant=mcp"
17+
providers: list[OAuthClientProvider] = []
18+
session_calls: list[tuple[Any, Any]] = []
19+
20+
class FakeCallbackServer:
21+
def __init__(self, port: int) -> None:
22+
assert port == 3030
23+
24+
def start(self) -> None:
25+
pass
26+
27+
@asynccontextmanager
28+
async def fake_sse_client(**kwargs: Any) -> AsyncIterator[tuple[object, object]]:
29+
assert kwargs["url"] == resource_url
30+
assert isinstance(kwargs["auth"], OAuthClientProvider)
31+
providers.append(kwargs["auth"])
32+
yield object(), object()
33+
34+
async def fake_run_session(self: SimpleAuthClient, read_stream: Any, write_stream: Any) -> None:
35+
session_calls.append((read_stream, write_stream))
36+
37+
monkeypatch.setattr(client_module, "CallbackServer", FakeCallbackServer)
38+
monkeypatch.setattr(client_module, "sse_client", fake_sse_client)
39+
monkeypatch.setattr(SimpleAuthClient, "_run_session", fake_run_session)
40+
41+
await SimpleAuthClient(resource_url, transport_type="sse").connect()
42+
43+
assert [provider.context.server_url for provider in providers] == [resource_url]
44+
assert len(session_calls) == 1

0 commit comments

Comments
 (0)