Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b69ca5d
[Client] Don't log a pending authorization as a transport error when …
chr-hertel Nov 10, 2026
746940e
[Client] Key stored tokens by grant and client, not only by issuer an…
chr-hertel Nov 10, 2026
e767e5d
[Docs] Name the HTTP clients that follow redirects through PSR-18
chr-hertel Nov 10, 2026
83b28c7
[Client] Note in the changelog what a redirect exposed before 0.10.0
chr-hertel Nov 10, 2026
466f892
[Client] Refuse an authorization server or endpoint in a private netw…
chr-hertel Nov 10, 2026
64878b2
[Client] Turn off redirects on a Symfony client passed to HttpTranspo…
chr-hertel Nov 10, 2026
70e8690
[Client] Try the next metadata location when a document describes ano…
chr-hertel Nov 10, 2026
c401957
[Client] Refresh a token before it expires instead of waiting for the…
chr-hertel Nov 10, 2026
0475224
[Client] Don't retry a request whose body can't tell or seek instead …
chr-hertel Nov 10, 2026
61bbbcc
[Client] Credit bbdu3 as author of the deferred authorization classes
chr-hertel Nov 10, 2026
3f7b863
[Client] Keep the token when the storage fails during the refresh bef…
chr-hertel Nov 10, 2026
9c70893
[Client] Seed the service account tests under the key the service acc…
chr-hertel Nov 10, 2026
1fc8200
[Client] Keep the token when looking up its client fails during the r…
chr-hertel Nov 10, 2026
f14f235
[Docs] Say that an expected issuer doesn't allow loopback or private …
chr-hertel Nov 10, 2026
55fd0c1
[Client] Record the client a token was issued to and never use a toke…
chr-hertel Nov 10, 2026
b7a1379
[Client] Use the email address bbdu3 commits with in the author tags
chr-hertel Nov 10, 2026
b6362c9
[Client] Don't repeat a failed refresh before sending for the same token
chr-hertel Nov 10, 2026
90cff68
[Docs] Say that the private network check looks at IP addresses, not …
chr-hertel Nov 10, 2026
7810b72
[Client] Name the issuer checks of the endpoint validation
chr-hertel Nov 10, 2026
51700a0
[Client] Describe the token key correctly in the scope limit
chr-hertel Nov 10, 2026
42454d3
[Client] Refuse metadata for another resource in the discovery, inste…
chr-hertel Nov 10, 2026
79608ad
[Server] Use the email address bbdu3 commits with in the author tags
chr-hertel Nov 10, 2026
92979d0
[Client] Also count NAT64, 6to4 and site-local addresses as a private…
chr-hertel Nov 10, 2026
b683845
[Client] Don't refresh a token before sending that lives shorter than…
chr-hertel Nov 10, 2026
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Fix the stdio client hanging on Windows while the server stays silent, without enforcing its timeouts: `StdioTransport` now reads only once `stream_select()` reports output. Requires PHP 8.5, as `stream_select()` on Windows reports an idle pipe as readable before that.
* [BC Break] Remove JSON-RPC batch support: a JSON array from the server is dropped with a warning. Batching was removed from MCP in `2025-06-18`.
* [BC Break] Fix `HttpTransport` reading a response body that is not an SSE stream without a limit, which could exhaust client memory on a large body. It now reads up to the new `maxResponseBodyBytes` argument (default: 8 MiB): a successful response over that limit fails the request, and an error status keeps its body cut at the limit. Raise the limit if your server sends larger results.
* [BC Break] `HttpTransport` turns off redirects on a discovered `symfony/http-client` client and treats every `3xx` answer as an error that names the status and target. An endpoint that is reached through a redirect stops working, so configure its final URL. A client you pass must not follow redirects to another origin.
* [BC Break] `HttpTransport` turns off redirects on a `symfony/http-client` client, discovered or passed, and treats every `3xx` answer as an error that names the status and target. Before, a redirect to another origin received the configured headers, such as an API key, and on a `307` or `308` the request body. If an endpoint of yours may have redirected to a host you don't control, rotate those credentials. An endpoint that is reached through a redirect stops working, so configure its final URL. A client you pass must not follow redirects to another origin.
* Fix `HttpTransport` taking an `Mcp-Session-Id` from any response: it replaced the session mid-way and, on `2026-07-28`, made `getSessionId()` return a value and `close()` send a `DELETE`. Only the answer to `initialize` sets it.
* Add `OAuth::forServiceAccount()` for the client credentials grant, `OAuth::setPrivateKeyJwt()` for `private_key_jwt` client authentication and `OAuth::setCrossAppAccess()` to reach a server through an enterprise identity.
* Add `OAuth::setClientMetadataUrl()`, to use a client ID metadata document where the authorization server supports it, and `ConsoleAuthorizationHandler` for a shell without a browser.
Expand Down
14 changes: 9 additions & 5 deletions docs/client/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ The file contains tokens and client secrets. The SDK creates it with the mode `0

The storage keys everything by the issuer of the authorization server. If a server moves to another authorization server, the client registers again and doesn't send its old client ID to the new one. It also registers again if you change the redirect URI, if it needs scopes that its registration doesn't include, or if the token endpoint answers `invalid_client` for a client that it registered.

Tokens are also stored per resource, grant and client ID. So you can share one storage between a service account and an application for users, or between clients with different client IDs. None of them uses a token of another. Each token also records the client it was issued to, and a client never uses or refreshes a token of another, e.g. after a dynamic registration was replaced. A storage of your own must keep that client ID, which `AccessToken::jsonSerialize()` includes.

## Finishing the Authorization in a Later Request

A web application can't wait for the browser within one request. It redirects the user to the authorization server, which sends the user back to a callback route of the application. Use `RedirectAuthorizationHandler` for this, with one storage per user session:
Expand Down Expand Up @@ -246,7 +248,7 @@ OAuth::forApplication('My App')->setScopes('mcp:read', 'mcp:write');

If the server answers with `insufficient_scope`, the client adds the scopes of this challenge and authorizes again. It doesn't authorize again for scopes that it already requested. With any other `403`, the client doesn't authorize again.

If the authorization server supports `offline_access`, the client requests it to get a refresh token. If the server refuses this scope, the client tries again without it. Use `setOfflineAccess(false)` to never request this scope. The server can still issue a refresh token, and the client then uses it.
If the authorization server supports `offline_access`, the client requests it to get a refresh token. If the server refuses this scope, the client tries again without it. Use `setOfflineAccess(false)` to never request this scope. The server can still issue a refresh token, and the client then uses it. It refreshes a token 30 seconds before it expires, so the server doesn't have to reject a request first. A token that lives shorter than that is only refreshed once the server rejects it.

## Connecting to Older Servers

Expand All @@ -272,11 +274,13 @@ The client treats the MCP server and its metadata as untrusted input. It stops w

| Input | Reason |
| --- | --- |
| The protected resource metadata names a `resource` that doesn't cover the endpoint | The token would be issued for another server |
| No location of the protected resource metadata names a `resource` that covers the endpoint | The token would be issued for another server |
| The authorization server metadata names another `issuer` than the one it was fetched for | The document describes another server |
| The MCP server URL, an authorization server URL or an endpoint doesn't use `https` on a host that isn't loopback | Credentials would travel without encryption |
| One of these URLs names `0.0.0.0`, `::`, an IPv4 address within IPv6 or an IPv4 address in another form than four decimal numbers (e.g. `127.1`), or writes its host with percent-encoding or non-ASCII characters | The request could reach a local service or a private network without looking like it |
| A server that isn't on loopback names a loopback authorization server or endpoint, unless `setExpectedIssuer()` allows that issuer | It could reach services that only listen on this machine |
| A server that isn't on loopback names a loopback authorization server, unless `setExpectedIssuer()` allows that issuer, or an authorization server that isn't on loopback names a loopback endpoint, even with `setExpectedIssuer()` | It could reach services that only listen on this machine |
| An MCP server whose URL isn't a loopback or private-network IP address names an IP address of a private or link-local network (e.g. `10.0.0.5` or `169.254.169.254`) as authorization server, unless `setExpectedIssuer()` allows that issuer. A DNS name counts as public, so an MCP server at `https://mcp.corp.internal` with its authorization server at `https://10.0.0.5` needs `setExpectedIssuer()` | It could reach services of the network the client runs in |
| An authorization server whose URL isn't a loopback or private-network IP address names such an address as endpoint, even with `setExpectedIssuer()` | The same |
| The authorization server metadata doesn't list `S256` in `code_challenge_methods_supported` | The client can't use PKCE with this server |
| The authorization endpoint is on another origin than the issuer and the metadata doesn't announce `iss`, without `setExpectedIssuer()` | A response could come from another server, and the code would go to this one |
| The authorization response has no `state`, or another `state` | It doesn't answer the request of this client |
Expand All @@ -286,10 +290,10 @@ The client treats the MCP server and its metadata as untrusted input. It stops w
| With `setLegacyDiscovery(true)`, a metadata location answers anything but a `404` and gives no usable metadata | The client only uses the fixed endpoints after a `404` |
| The server has no protected resource metadata, without `setLegacyDiscovery(true)` | There is nothing to check the authorization server against |

These checks only look at how a URL writes its host. A DNS name that points at loopback or a private network still gets through. If you run the client on a server, block requests to private networks in your HTTP client or proxy (e.g. with Symfony's `NoPrivateNetworkHttpClient`) and pass that client to `setHttpClient()`. Pass it to `HeadlessAuthorizationHandler` as well, which sends its own request to the authorization endpoint.
These checks only look at how a URL writes its host. A DNS name that points at loopback or a private network still gets through, as the client doesn't resolve names. If you run the client on a server, block requests to private networks in your HTTP client or proxy (e.g. with Symfony's `NoPrivateNetworkHttpClient`) and pass that client to `setHttpClient()`. Pass it to `HeadlessAuthorizationHandler` as well, which sends its own request to the authorization endpoint.

!!! warning
A client that you pass to `setHttpClient()` *must not* follow redirects. The client only turns them off on a `Psr18Client` of `symfony/http-client` 6.2 or later. With redirects on, an authorization server can send the authorization code and the client credentials to any address.
A client that you pass to `setHttpClient()` *must not* follow redirects. The client only turns them off on a `Psr18Client` of `symfony/http-client` 6.2 or later. Guzzle doesn't follow redirects through PSR-18, but an HTTPlug adapter around a Guzzle client with the default handler stack, HTTPlug's `RedirectPlugin` and Symfony's `HttplugClient` do. With redirects on, an authorization server can send the authorization code and the client credentials to any address.

The client ignores a `resource_metadata` URL on another origin than the MCP server and looks at the well-known URLs instead. It only sends a token to the resource it was issued for.

Expand Down
12 changes: 8 additions & 4 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,19 @@ If the server answers a request with a `3xx` status, the request fails at once w
`ConnectionException` at once, with no retry and no fallback to the `initialize` handshake. To
reach a server that moved, configure its new URL as `endpoint`.

The transport turns off redirects on a `symfony/http-client` client that it discovers. With `auth`,
it also turns them off on a `symfony/http-client` client that you pass as `httpClient`. Any other
client keeps its own configuration.
The transport turns off redirects on a `symfony/http-client` client, whether it discovers the client
or you pass it as `httpClient`. Any other client keeps its own configuration.

Such a client *must not* follow redirects to another origin. If it does, your headers and the
request body reach that origin, and the transport accepts its answer as the server's. With `auth`,
it *must not* follow any redirect, because a redirect on the same origin also receives the token.

To pass a `symfony/http-client` client, create it with `max_redirects` set to `0`:
Guzzle doesn't follow redirects through PSR-18. An HTTPlug adapter around a Guzzle client with the
default handler stack (e.g. `php-http/guzzle7-adapter`), HTTPlug's `RedirectPlugin` and Symfony's
`HttplugClient` do.

Before `symfony/http-client` 6.2, the transport can't change a client that you pass. Create it with
`max_redirects` set to `0`:

```php
use Symfony\Component\HttpClient\HttpClient;
Expand Down
19 changes: 18 additions & 1 deletion src/Client/Auth/AccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ final class AccessToken implements \JsonSerializable
* @param ?int $expiresAt unix timestamp the token expires at, null when the server did not say
* @param ?string $refreshToken the refresh token, when the grant produced one
* @param string[] $scopes the scopes the authorization server actually granted
* @param ?string $clientId the client the token was issued to, null when unknown
*/
public function __construct(
public readonly string $accessToken,
public readonly string $tokenType = 'Bearer',
public readonly ?int $expiresAt = null,
public readonly ?string $refreshToken = null,
public readonly array $scopes = [],
public readonly ?string $clientId = null,
) {
if ('' === trim($accessToken)) {
throw new InvalidArgumentException('The access token must not be empty.');
Expand Down Expand Up @@ -122,7 +124,20 @@ public function withGrantedScopes(array $scopes): self
return $this;
}

return new self($this->accessToken, $this->tokenType, $this->expiresAt, $this->refreshToken, $scopes);
return new self($this->accessToken, $this->tokenType, $this->expiresAt, $this->refreshToken, $scopes, $this->clientId);
}

public function withClientId(string $clientId): self
{
return new self($this->accessToken, $this->tokenType, $this->expiresAt, $this->refreshToken, $this->scopes, $clientId);
}

/**
* Whether the token may be used by this client: a token and its refresh token belong to the client they were issued to (RFC 6749 section 6).
*/
public function isIssuedTo(?string $clientId): bool
{
return null === $this->clientId || $this->clientId === $clientId;
}

public function getAuthorizationHeader(): string
Expand All @@ -141,6 +156,7 @@ public function jsonSerialize(): array
'expires_at' => $this->expiresAt,
'refresh_token' => $this->refreshToken,
'scopes' => $this->scopes,
'client_id' => $this->clientId,
], static fn (mixed $value): bool => null !== $value && [] !== $value);
}

Expand All @@ -155,6 +171,7 @@ public static function fromArray(array $data): self
isset($data['expires_at']) && is_numeric($data['expires_at']) ? (int) $data['expires_at'] : null,
\is_string($data['refresh_token'] ?? null) ? $data['refresh_token'] : null,
array_values(array_filter((array) ($data['scopes'] ?? []), 'is_string')),
\is_string($data['client_id'] ?? null) ? $data['client_id'] : null,
);
}

Expand Down
23 changes: 21 additions & 2 deletions src/Client/Auth/AuthenticatingHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;

/**
* A PSR-18 client that authenticates every request and retries the ones the
Expand Down Expand Up @@ -63,7 +64,7 @@ public function sendUnchallenged(RequestInterface $request): ResponseInterface
public function sendRequest(RequestInterface $request): ResponseInterface
{
$body = $request->getBody();
$position = $body->isSeekable() ? $body->tell() : null;
$position = self::replayPosition($body);

$sent = $this->authenticator->authenticate($request);
$response = $this->client->sendRequest($sent);
Expand Down Expand Up @@ -97,13 +98,31 @@ public function sendRequest(RequestInterface $request): ResponseInterface
return $response;
}

try {
$body->seek($position);
} catch (\RuntimeException) {
return $response;
}

$response->getBody()->close();
$body->seek($position);

$sent = $this->authenticator->authenticate($request);
$response = $this->client->sendRequest($sent);
}

return $response;
}

private static function replayPosition(StreamInterface $body): ?int
{
if (!$body->isSeekable()) {
return null;
}

try {
return $body->tell();
} catch (\RuntimeException) {
return null;
}
}
}
14 changes: 12 additions & 2 deletions src/Client/Auth/AuthorizationServerMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ public static function fromArray(array $data): self
}
}

/** @var string $issuer */
$issuer = $data['issuer'];
$issuerOnLoopback = SecureUrl::isLoopback($issuer);
$issuerIsPublic = !$issuerOnLoopback && !SecureUrl::isPrivateNetwork($issuer);

foreach (['authorization_endpoint', 'token_endpoint', 'registration_endpoint'] as $endpoint) {
if (!\is_string($data[$endpoint] ?? null)) {
continue;
Expand All @@ -84,8 +89,13 @@ public static function fromArray(array $data): self
}

// Only a server on this machine may send the client back to this machine.
if (SecureUrl::isLoopback($data[$endpoint]) && \is_string($data['issuer'] ?? null) && !SecureUrl::isLoopback($data['issuer'])) {
throw new InvalidArgumentException(\sprintf('The authorization server metadata of "%s" declares "%s" as "%s", which is a loopback address.', $data['issuer'], $endpoint, $data[$endpoint]));
if (!$issuerOnLoopback && SecureUrl::isLoopback($data[$endpoint])) {
throw new InvalidArgumentException(\sprintf('The authorization server metadata of "%s" declares "%s" as "%s", which is a loopback address.', $issuer, $endpoint, $data[$endpoint]));
}

// Nor may a server outside a private network send the client into one.
if ($issuerIsPublic && SecureUrl::isPrivateNetwork($data[$endpoint])) {
throw new InvalidArgumentException(\sprintf('The authorization server metadata of "%s" declares "%s" as "%s", which is an address of a private network.', $issuer, $endpoint, $data[$endpoint]));
}

$parts = SecureUrl::parse($data[$endpoint], $endpoint);
Expand Down
2 changes: 1 addition & 1 deletion src/Client/Auth/CredentialStorageInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function saveClientRegistration(string $issuer, ClientRegistration $regis

/**
* @param string $issuer the authorization server's issuer identifier
* @param string $resource the canonical URI of the protected resource the token may be sent to
* @param string $resource the canonical URI of the protected resource the token may be sent to, followed by `#`, the grant and, for a configured client, its client ID
*/
public function getToken(string $issuer, string $resource): ?AccessToken;

Expand Down
26 changes: 25 additions & 1 deletion src/Client/Auth/MetadataDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Mcp\Client\Auth;

use Mcp\Exception\AuthorizationException;
use Mcp\Exception\InvalidArgumentException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
Expand Down Expand Up @@ -39,9 +40,15 @@ public function __construct(
*
* @param string $resourceUrl the MCP endpoint being called
* @param ?string $metadataUrl the location the challenge named, which wins over any guess
*
* @return ?ProtectedResourceMetadata the first document that covers the endpoint, null if there is none
*
* @throws AuthorizationException if documents were found, but only for other resources
*/
public function discoverProtectedResource(string $resourceUrl, ?string $metadataUrl = null): ?ProtectedResourceMetadata
{
$unrelated = null;

foreach (null !== $metadataUrl ? [$metadataUrl] : self::protectedResourceCandidates($resourceUrl) as $candidate) {
if (!AuthorizationServerMetadata::isTransportSecure($candidate)) {
$this->logger->error('Refusing protected resource discovery: the metadata URL is neither an HTTPS URL nor a loopback address.', ['url' => $candidate]);
Expand All @@ -56,10 +63,27 @@ public function discoverProtectedResource(string $resourceUrl, ?string $metadata
}

try {
return ProtectedResourceMetadata::fromArray($document);
$metadata = ProtectedResourceMetadata::fromArray($document);
} catch (InvalidArgumentException $e) {
$this->logger->warning('Ignoring malformed protected resource metadata', ['url' => $candidate, 'exception' => $e]);

continue;
}

// RFC 9728 section 3.3: the document must not be used, but a later location may describe this resource.
if (!$metadata->covers($resourceUrl)) {
$this->logger->warning('Ignoring protected resource metadata for another resource', ['url' => $candidate, 'resource' => $metadata->resource]);
$unrelated ??= $metadata;

continue;
}

return $metadata;
}

if (null !== $unrelated) {
// A token for another resource would be sent to this one: a confused deputy.
throw new AuthorizationException(\sprintf('The protected resource metadata for "%s" declares the unrelated resource "%s"; refusing to authorize against it.', $resourceUrl, $unrelated->resource));
}

return null;
Expand Down
Loading
Loading