Deprecate constructing the pre-provisioned OAuth clients without an issuer (#3435)
This commit is contained in:
parent
d060b36e1d
commit
979208c115
3 changed files with 103 additions and 11 deletions
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
from inline_snapshot import snapshot
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from mcp import MCPDeprecationWarning
|
||||
from mcp.client.auth import OAuthClientProvider, OAuthFlowError
|
||||
from mcp.client.auth.extensions.client_credentials import (
|
||||
ClientCredentialsOAuthProvider,
|
||||
|
|
@ -57,6 +58,7 @@ class TestClientCredentialsOAuthProvider:
|
|||
storage=mock_storage,
|
||||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
issuer="https://api.example.com",
|
||||
)
|
||||
|
||||
# client_info is set during _initialize
|
||||
|
|
@ -77,6 +79,7 @@ class TestClientCredentialsOAuthProvider:
|
|||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
scope="read write",
|
||||
issuer="https://api.example.com",
|
||||
)
|
||||
|
||||
await provider._initialize()
|
||||
|
|
@ -92,6 +95,7 @@ class TestClientCredentialsOAuthProvider:
|
|||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
issuer="https://api.example.com",
|
||||
)
|
||||
|
||||
await provider._initialize()
|
||||
|
|
@ -107,6 +111,7 @@ class TestClientCredentialsOAuthProvider:
|
|||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
scope="read write",
|
||||
issuer="https://api.example.com",
|
||||
)
|
||||
provider.context.oauth_metadata = OAuthMetadata(
|
||||
issuer=AnyHttpUrl("https://api.example.com"),
|
||||
|
|
@ -135,6 +140,7 @@ class TestClientCredentialsOAuthProvider:
|
|||
client_secret="test-client-secret",
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="read write",
|
||||
issuer="https://api.example.com",
|
||||
)
|
||||
await provider._initialize()
|
||||
provider.context.oauth_metadata = OAuthMetadata(
|
||||
|
|
@ -161,6 +167,7 @@ class TestClientCredentialsOAuthProvider:
|
|||
storage=mock_storage,
|
||||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
issuer="https://api.example.com",
|
||||
)
|
||||
provider.context.oauth_metadata = OAuthMetadata(
|
||||
issuer=AnyHttpUrl("https://api.example.com"),
|
||||
|
|
@ -192,6 +199,7 @@ class TestPrivateKeyJWTOAuthProvider:
|
|||
storage=mock_storage,
|
||||
client_id="test-client-id",
|
||||
assertion_provider=mock_assertion_provider,
|
||||
issuer="https://api.example.com",
|
||||
)
|
||||
|
||||
# client_info is set during _initialize
|
||||
|
|
@ -215,6 +223,7 @@ class TestPrivateKeyJWTOAuthProvider:
|
|||
client_id="test-client-id",
|
||||
assertion_provider=mock_assertion_provider,
|
||||
scope="read write",
|
||||
issuer="https://auth.example.com",
|
||||
)
|
||||
provider.context.oauth_metadata = OAuthMetadata(
|
||||
issuer=AnyHttpUrl("https://auth.example.com"),
|
||||
|
|
@ -246,6 +255,7 @@ class TestPrivateKeyJWTOAuthProvider:
|
|||
storage=mock_storage,
|
||||
client_id="test-client-id",
|
||||
assertion_provider=mock_assertion_provider,
|
||||
issuer="https://auth.example.com",
|
||||
)
|
||||
provider.context.oauth_metadata = OAuthMetadata(
|
||||
issuer=AnyHttpUrl("https://auth.example.com"),
|
||||
|
|
@ -436,6 +446,65 @@ async def test_provider_picks_its_configured_issuer_among_several_advertised_ser
|
|||
await flow.aclose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["secret", "jwt"])
|
||||
def test_constructing_without_issuer_is_deprecated(mock_storage: MockTokenStorage, kind: str) -> None:
|
||||
"""SDK-defined: leaving `issuer` out is allowed, and the provider says at construction that
|
||||
token requests will follow whichever authorization server the MCP server advertises."""
|
||||
|
||||
async def assertion_provider(audience: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
with pytest.warns(MCPDeprecationWarning) as recorded:
|
||||
if kind == "secret":
|
||||
ClientCredentialsOAuthProvider(
|
||||
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
|
||||
)
|
||||
else:
|
||||
PrivateKeyJWTOAuthProvider(
|
||||
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
|
||||
)
|
||||
|
||||
[warning] = recorded
|
||||
assert warning.filename == __file__
|
||||
assert str(warning.message) == (
|
||||
"Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server "
|
||||
"decides which authorization server receives this client's credentials; pass "
|
||||
"issuer=<your authorization server's issuer URL> so they are only ever sent there."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("kind", ["secret", "jwt"])
|
||||
async def test_without_issuer_the_exchange_follows_whichever_server_was_discovered(
|
||||
mock_storage: MockTokenStorage, kind: str
|
||||
) -> None:
|
||||
"""SDK-defined: with no `issuer` configured the token request is built from whatever metadata
|
||||
discovery produced, as before."""
|
||||
|
||||
async def assertion_provider(audience: str) -> str:
|
||||
return "jwt"
|
||||
|
||||
with pytest.warns(MCPDeprecationWarning, match="Omitting `issuer` is deprecated"):
|
||||
if kind == "secret":
|
||||
provider: OAuthClientProvider = ClientCredentialsOAuthProvider(
|
||||
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
|
||||
)
|
||||
else:
|
||||
provider = PrivateKeyJWTOAuthProvider(
|
||||
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
|
||||
)
|
||||
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
|
||||
|
||||
token_request = await _answer_discovery(
|
||||
flow,
|
||||
authorization_server="https://elsewhere.example.com",
|
||||
metadata=_metadata_for("https://elsewhere.example.com"),
|
||||
)
|
||||
|
||||
assert (token_request.method, str(token_request.url)) == ("POST", "https://elsewhere.example.com/token")
|
||||
await flow.aclose()
|
||||
|
||||
|
||||
def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None:
|
||||
"""SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
|
||||
error on both machine-to-machine providers."""
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None:
|
|||
storage=tutorial002.InMemoryTokenStorage(),
|
||||
client_id="reporting-agent",
|
||||
assertion_provider=static_assertion_provider("a.prebuilt.jwt"),
|
||||
issuer="http://localhost:9000",
|
||||
)
|
||||
assert isinstance(provider, OAuthClientProvider)
|
||||
assert isinstance(provider, httpx2.Auth)
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_
|
|||
client_id="m2m-client",
|
||||
client_secret="m2m-secret",
|
||||
scope="mcp",
|
||||
issuer=BASE_URL,
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
|
|
@ -424,6 +425,7 @@ async def test_private_key_jwt_provider_authenticates_the_token_request_with_an_
|
|||
client_id="m2m-jwt-client",
|
||||
assertion_provider=assertion_provider,
|
||||
scope="mcp",
|
||||
issuer=BASE_URL,
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
|
|
|
|||
Loading…
Reference in a new issue