Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli
What changed:

* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely.
* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leave it out and the provider uses whichever authorization server discovery finds.
* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leaving it out is deprecated and it becomes required in 3.0 (see **[Deprecated features](../deprecated.md#deprecated-sdk-helpers)**); until then the provider warns and uses whichever authorization server discovery finds.
* `scope` is a space-separated string, the OAuth wire format.
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.

Expand Down
7 changes: 4 additions & 3 deletions docs/deprecated.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Deprecated features

The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. One SDK helper is deprecated on its own account and is listed [at the end](#deprecated-sdk-helpers).
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. Two SDK-level deprecations stand on their own account and are listed [at the end](#deprecated-sdk-helpers).

The table below names each deprecated feature, why it is going away, and the replacement to build on.

Expand Down Expand Up @@ -131,11 +131,12 @@ That is the whole API. There is no per-method switch, and you don't want one: th

## Deprecated SDK helpers

These are not spec changes, only SDK internals with a better replacement. They warn with the same `MCPDeprecationWarning` and will be removed in 3.0.
These are not spec changes, only SDK usage with a better replacement. They warn with the same `MCPDeprecationWarning`, and 3.0 removes the old form.

| Deprecated | What you do instead |
|---|---|
| `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. |
| `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. |

## Recap

Expand All @@ -144,7 +145,7 @@ These are not spec changes, only SDK internals with a better replacement. They w
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
* One SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, is deprecated separately for removal in 3.0.
* Two SDK-level deprecations ride along: `FuncMetadata.call_fn_with_arg_validation()` is removed in 3.0, and constructing `ClientCredentialsOAuthProvider` / `PrivateKeyJWTOAuthProvider` without `issuer=` stops being allowed in 3.0.
* New code should not be built on any of these.

Every other page in these docs teaches the current API.
5 changes: 3 additions & 2 deletions examples/stories/oauth_client_credentials/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

# MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from
# the same constant — run the server on 8000 or the discovery chain points at the wrong origin.
from stories._shared.auth import MCP_URL, InMemoryTokenStorage
from stories._shared.auth import BASE_URL, MCP_URL, InMemoryTokenStorage

from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE


def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config.
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — six lines of provider config.
Comment thread
maxisbey marked this conversation as resolved.
Resolved

The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST →
Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the
Expand All @@ -27,6 +27,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
client_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET,
scope=DEMO_SCOPE,
issuer=BASE_URL,
)


Expand Down
19 changes: 16 additions & 3 deletions src/mcp/client/auth/extensions/client_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""

import time
import warnings
from collections.abc import Awaitable, Callable
from typing import Any, Literal
from urllib.parse import urlparse
Expand All @@ -20,10 +21,20 @@
from mcp.client.auth.oauth2 import OAuthContext
from mcp.client.auth.utils import issuers_match
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
from mcp.shared.exceptions import MCPDeprecationWarning


def _checked_issuer(issuer: str | None) -> str | None:
if issuer is not None and urlparse(issuer).scheme not in ("http", "https"):
if issuer is None:
warnings.warn(
"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.",
MCPDeprecationWarning,
stacklevel=3,
)
return None
if urlparse(issuer).scheme not in ("http", "https"):
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
return issuer

Expand Down Expand Up @@ -97,7 +108,8 @@ def __init__(
issuer: The issuer identifier of the authorization server that issued
`client_id` and `client_secret`. When set, token requests are only built from
discovered authorization server metadata whose `issuer` is exactly this string;
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated
(`MCPDeprecationWarning`) and it will be required in 3.0; until then, whichever
authorization server discovery yields is used.
"""
# Build minimal client_metadata for the base class
Expand Down Expand Up @@ -327,7 +339,8 @@ def __init__(
registered with. When set, an assertion is only minted, and token requests
are only built, once authorization server metadata whose `issuer` is exactly this
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
When omitted, whichever authorization server discovery yields is used.
Omitting it is deprecated (`MCPDeprecationWarning`) and it will be required in
3.0; until then, whichever authorization server discovery yields is used.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
Expand Down
69 changes: 69 additions & 0 deletions tests/client/auth/extensions/test_client_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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,
Expand Down Expand Up @@ -57,6 +58,7 @@ async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
issuer="https://api.example.com",
)

# client_info is set during _initialize
Expand All @@ -77,6 +79,7 @@ async def test_init_with_scopes(self, mock_storage: MockTokenStorage):
client_id="test-client-id",
client_secret="test-client-secret",
scope="read write",
issuer="https://api.example.com",
)

await provider._initialize()
Expand All @@ -92,6 +95,7 @@ async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage
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()
Expand All @@ -107,6 +111,7 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt
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"),
Expand Down Expand Up @@ -135,6 +140,7 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s
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(
Expand All @@ -161,6 +167,7 @@ async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorag
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"),
Expand Down Expand Up @@ -192,6 +199,7 @@ async def mock_assertion_provider(audience: str) -> str: # pragma: no cover
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
issuer="https://api.example.com",
)

# client_info is set during _initialize
Expand All @@ -215,6 +223,7 @@ async def mock_assertion_provider(audience: str) -> str:
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"),
Expand Down Expand Up @@ -246,6 +255,7 @@ async def mock_assertion_provider(audience: str) -> str:
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"),
Expand Down Expand Up @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions tests/docs_src/test_oauth_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions tests/interaction/auth/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -424,6 +425,7 @@ async def assertion_provider(audience: str) -> str:
client_id="m2m-jwt-client",
assertion_provider=assertion_provider,
scope="mcp",
issuer=BASE_URL,
)

with anyio.fail_after(5):
Expand Down
Loading