Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/mcp_stdio/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,15 @@ def discover_oauth_metadata(
if meta:
return meta

# Path-scoped issuers (Keycloak realm URLs, AWS Cognito user pools, etc.)
# publish metadata at /.well-known/oauth-authorization-server/<path> per
# RFC 8414 §3 path-insertion. Try the original server_url when it has a
# path component that neither the PRM-discovered AS nor the base URL tried.
if server_url not in (auth_server_url, base):
meta = _fetch_authorization_server_metadata(server_url, client)
if meta:
return meta

# Phase 3: Default paths
log("OAuth metadata not found, using default endpoints")
return OAuthMetadata(
Expand Down
79 changes: 78 additions & 1 deletion tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,20 @@ def test_fallback_on_404(self, httpx_mock):
url="https://api.example.com/.well-known/oauth-authorization-server",
status_code=404,
)
# Path-scoped probe (new fallback for Keycloak-style issuers)
httpx_mock.add_response(
url="https://api.example.com/.well-known/oauth-authorization-server/mcp",
status_code=404,
)
client = httpx.Client()
meta = discover_oauth_metadata("https://api.example.com/mcp", client)
assert meta.authorization_endpoint == "https://api.example.com/authorize"
assert meta.token_endpoint == "https://api.example.com/token"
assert meta.registration_endpoint == "https://api.example.com/register"

def test_fallback_on_connection_error(self, httpx_mock):
# ConnectError affects path-aware PRM, host-root PRM, and AS metadata
# ConnectError for: path-aware PRM, host-root PRM, host-root AS, path-scoped AS
httpx_mock.add_exception(httpx.ConnectError("refused"))
httpx_mock.add_exception(httpx.ConnectError("refused"))
httpx_mock.add_exception(httpx.ConnectError("refused"))
httpx_mock.add_exception(httpx.ConnectError("refused"))
Expand Down Expand Up @@ -206,6 +212,10 @@ def test_invalid_json_response(self, httpx_mock):
text="not json",
status_code=200,
)
httpx_mock.add_response(
url="https://api.example.com/.well-known/oauth-authorization-server/mcp",
status_code=404,
)
client = httpx.Client()
meta = discover_oauth_metadata("https://api.example.com/mcp", client)
# Should fallback to defaults
Expand All @@ -217,6 +227,10 @@ def test_server_500(self, httpx_mock):
url="https://api.example.com/.well-known/oauth-authorization-server",
status_code=500,
)
httpx_mock.add_response(
url="https://api.example.com/.well-known/oauth-authorization-server/mcp",
status_code=404,
)
client = httpx.Client()
meta = discover_oauth_metadata("https://api.example.com/mcp", client)
assert meta.token_endpoint == "https://api.example.com/token"
Expand Down Expand Up @@ -275,6 +289,10 @@ def test_rfc9728_non_json_404_handled(self, httpx_mock):
url="https://api.example.com/.well-known/oauth-authorization-server",
status_code=404,
)
httpx_mock.add_response(
url="https://api.example.com/.well-known/oauth-authorization-server/mcp",
status_code=404,
)
client = httpx.Client()
meta = discover_oauth_metadata("https://api.example.com/mcp", client)
# Falls through to defaults
Expand Down Expand Up @@ -529,6 +547,55 @@ def test_rfc9728_resource_match_no_warning(self, httpx_mock):
meta = discover_oauth_metadata("https://api.example.com/mcp", client)
assert meta.authorization_endpoint == "https://auth.example.com/authorize"

def test_path_scoped_issuer_keycloak_style(self, httpx_mock):
"""#53: Keycloak/Cognito path-scoped issuers advertise metadata at RFC 8414 §3
path-insertion URL (/.well-known/oauth-authorization-server/<path>).

When the MCP server URL IS the issuer (no separate authorization server),
host-root RFC 8414 returns 404, but the path-insertion well-known succeeds.
Repro: mcp-stdio --oauth-device --client-id x http://keycloak/realms/test
"""
# No PRM (Keycloak doesn't implement RFC 9728)
self._mock_no_prm(httpx_mock, base="https://keycloak.example.com", path="/realms/test")
# Host-root AS metadata: 404
httpx_mock.add_response(
url="https://keycloak.example.com/.well-known/oauth-authorization-server",
status_code=404,
)
# Path-insertion AS metadata (RFC 8414 §3): 200 with device endpoint
httpx_mock.add_response(
url="https://keycloak.example.com/.well-known/oauth-authorization-server/realms/test",
json={
"issuer": "https://keycloak.example.com/realms/test",
"authorization_endpoint": "https://keycloak.example.com/realms/test/protocol/openid-connect/auth",
"token_endpoint": "https://keycloak.example.com/realms/test/protocol/openid-connect/token",
"device_authorization_endpoint": "https://keycloak.example.com/realms/test/protocol/openid-connect/auth/device",
},
)
client = httpx.Client()
meta = discover_oauth_metadata(
"https://keycloak.example.com/realms/test", client
)
assert meta.authorization_endpoint == "https://keycloak.example.com/realms/test/protocol/openid-connect/auth"
assert meta.token_endpoint == "https://keycloak.example.com/realms/test/protocol/openid-connect/token"
assert meta.device_authorization_endpoint == "https://keycloak.example.com/realms/test/protocol/openid-connect/auth/device"

def test_path_scoped_issuer_does_not_shadow_host_root_match(self, httpx_mock):
"""#53: when host-root AS metadata succeeds, path-scoped probe is not called."""
self._mock_no_prm(httpx_mock, path="/realms/test")
# Host-root AS metadata: 200
httpx_mock.add_response(
url="https://api.example.com/.well-known/oauth-authorization-server",
json={
"authorization_endpoint": "https://api.example.com/auth",
"token_endpoint": "https://api.example.com/token",
},
)
# Path-insertion URL must NOT be called (no extra mock registered)
client = httpx.Client()
meta = discover_oauth_metadata("https://api.example.com/realms/test", client)
assert meta.authorization_endpoint == "https://api.example.com/auth"


# --- register_client ---

Expand Down Expand Up @@ -1499,6 +1566,11 @@ def test_refresh_failure_clears_stale_token(
url="https://example.com/.well-known/oauth-authorization-server",
status_code=404,
)
# Path-scoped probe (Keycloak-style fallback)
httpx_mock.add_response(
url="https://example.com/.well-known/oauth-authorization-server/mcp",
status_code=404,
)

# Stale token should be cleared after refresh failure
# Full OAuth flow will be attempted (and fail due to no browser),
Expand Down Expand Up @@ -1743,6 +1815,11 @@ def test_expired_secret_skips_refresh_and_clears_token(
url="https://example.com/.well-known/oauth-authorization-server",
status_code=404,
)
# Path-scoped probe (Keycloak-style fallback)
httpx_mock.add_response(
url="https://example.com/.well-known/oauth-authorization-server/mcp",
status_code=404,
)
# Full flow re-registers (since client_secret expired, not reused)
httpx_mock.add_response(
url="https://example.com/register",
Expand Down