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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add OAuth authorization: pass `OAuth::forApplication('My App')->build()` as `auth` and the client answers a `401` with discovery, dynamic or pre-registered client identity, the authorization code flow with PKCE and a retry. It refreshes tokens, handles step-up challenges and treats the server's metadata as untrusted input. See [Authorization](docs/client/authorization.md).
* `AuthenticatingHttpClient` and `OAuth` turn off redirects on a `symfony/http-client` client, so a redirect can't carry a token or client secret to another path or origin.
* Add `OAuth::setExpectedIssuer()`, required with `setClientCredentials()`, so a hostile MCP server can't receive the secret or an authorization code.
* Add `RedirectAuthorizationHandler` and `OAuthAuthenticator::finishAuthorization()`, to finish the authorization in a later request (e.g. the callback route of a web application). Its storage implements `PendingAuthorizationStorageInterface`, as `FileCredentialStorage` does.
* Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`.
* [BC Break] A `401` or `403` answer to a request, a notification or the resumption of an event stream fails with an `AuthorizationException` that contains the `WWW-Authenticate` header, instead of a `RequestException`. `Client::connect()` wraps it in a `ConnectionException`, without a retry.
* Fix `Client::connect()` leaving the previous transport open when called again: it closes the current transport first, so no stdio process or HTTP session is left behind.
Expand Down
59 changes: 59 additions & 0 deletions docs/client/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,63 @@ 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.

## 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:

```php
use Mcp\Client\Auth\FileCredentialStorage;
use Mcp\Client\Auth\OAuth;
use Mcp\Client\Auth\OAuthAuthenticator;
use Mcp\Client\Auth\RedirectAuthorizationHandler;

function createAuthenticator(): OAuthAuthenticator
{
return OAuth::forApplication('Acme Shop')
->setApplicationType('web')
->setRedirectUri('https://shop.example.com/oauth/callback')
->setAuthorizationHandler(new RedirectAuthorizationHandler())
->setCredentialStorage(new FileCredentialStorage('/var/lib/acme/mcp/'.hash('sha256', session_id()).'.json'))
->build();
}
```

Start the PHP session in both routes before you call this function. Without a session, `session_id()` returns an empty string, and all users share one file.

The client registers as a `native` application by default. Authorization servers may refuse an `https` redirect URI for it, so a web application calls `setApplicationType('web')`.

When the client needs a token, the handler throws an `AuthorizationPendingException`. Before that, the SDK stores the `state`, the PKCE verifier and the metadata of the authorization server. Redirect the browser to the URL of the exception:

```php
use Mcp\Client\Transport\HttpTransport;
use Mcp\Exception\AuthorizationPendingException;

try {
$client->connect(new HttpTransport('https://mcp.example.com/mcp', auth: createAuthenticator()));
} catch (AuthorizationPendingException $e) {
header('Location: '.$e->authorizationUrl);
exit;
}
```

`connect()` and the requests after it throw this exception directly. In the callback route, pass the query parameters to `finishAuthorization()` of an authenticator with the same configuration and storage:

```php
createAuthenticator()->finishAuthorization($_GET);

// back to the page that needs the MCP server
header('Location: /orders');
```

The SDK checks the callback like any other, exchanges the code and stores the token. The next `connect()` with this storage uses it. If the authorization server refuses `offline_access`, `finishAuthorization()` throws an `AuthorizationPendingException` again, to authorize without that scope.

A pending authorization expires after 10 minutes. The SDK removes it before it checks the callback, so each callback works once. If `finishAuthorization()` throws an `AuthorizationException`, send the user through the authorization again.

!!! warning
The SDK finds the pending authorization by the `state` parameter of the callback. Select the storage from the user session, never from the callback. With a storage that all users share, one user can finish an authorization that another user started.

`build()` throws an `InvalidArgumentException` if you use `RedirectAuthorizationHandler` without `setCredentialStorage()`. For your own storage, implement `PendingAuthorizationStorageInterface`. Its `consumePendingAuthorization()` method must read and remove the entry in one step, so two requests can't use the same callback.

## Using a Pre-registered Client

If an administrator created a client for you, or if the authorization server doesn't support dynamic registration, pass the client ID:
Expand Down Expand Up @@ -258,4 +315,6 @@ try {
}
```

An `AuthorizationPendingException` is never wrapped, see [Finishing the Authorization in a Later Request](#finishing-the-authorization-in-a-later-request).

After `connect()`, requests throw the `AuthorizationException` directly. The client doesn't retry a failed authorization. If the token endpoint is unavailable during a refresh, the request fails and the client keeps the refresh token. Pass a PSR-3 logger with `setLogger()` to see each step.
6 changes: 6 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Mcp\Client\Protocol;
use Mcp\Client\Transport\TransportInterface;
use Mcp\Exception\AuthorizationException;
use Mcp\Exception\AuthorizationPendingException;
use Mcp\Exception\ConnectionException;
use Mcp\Exception\ExceptionInterface;
use Mcp\Exception\InvalidArgumentException;
Expand Down Expand Up @@ -114,6 +115,11 @@ public function connect(TransportInterface $transport): void

$transport->close();

// The application has to send the user to the authorization server first.
if ($e instanceof AuthorizationPendingException) {
throw $e;
}

// Anything else, like a malformed initialize result, is not
// transient and would fail every retry the same way.
if (!$e instanceof ConnectionException) {
Expand Down
3 changes: 2 additions & 1 deletion src/Client/Auth/AuthorizationHandlerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ interface AuthorizationHandlerInterface
*
* @return array<string, string> the callback's query parameters
*
* @throws \Mcp\Exception\AuthorizationException when the user could not be sent, or did not come back
* @throws \Mcp\Exception\AuthorizationException when the user could not be sent, or did not come back
* @throws \Mcp\Exception\AuthorizationPendingException to persist the flow and redirect from a web application
*/
public function authorize(string $authorizationUrl, string $redirectUri): array;
}
21 changes: 20 additions & 1 deletion src/Client/Auth/AuthorizationServerMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
*
* @author Christopher Hertel <[email protected]>
*/
final class AuthorizationServerMetadata
final class AuthorizationServerMetadata implements \JsonSerializable
{
/**
* @param string $issuer the issuer identifier, which must match how the document was found
Expand Down Expand Up @@ -111,6 +111,25 @@ public static function fromArray(array $data): self
);
}

/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
return array_filter([
'issuer' => $this->issuer,
'authorization_endpoint' => $this->authorizationEndpoint,
'token_endpoint' => $this->tokenEndpoint,
'registration_endpoint' => $this->registrationEndpoint,
'scopes_supported' => $this->scopesSupported,
'grant_types_supported' => $this->grantTypesSupported,
'token_endpoint_auth_methods_supported' => $this->tokenEndpointAuthMethodsSupported,
'code_challenge_methods_supported' => $this->codeChallengeMethodsSupported,
'client_id_metadata_document_supported' => $this->clientIdMetadataDocumentSupported,
'authorization_response_iss_parameter_supported' => $this->issuerParameterSupported,
], static fn (mixed $value): bool => null !== $value);
}

/**
* The fixed endpoints a 2025-03-26 era server without metadata is assumed to expose.
*
Expand Down
57 changes: 50 additions & 7 deletions src/Client/Auth/FileCredentialStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*
* @author Christopher Hertel <[email protected]>
*/
final class FileCredentialStorage implements CredentialStorageInterface
final class FileCredentialStorage implements PendingAuthorizationStorageInterface
{
public function __construct(private readonly string $path)
{
Expand Down Expand Up @@ -74,15 +74,57 @@ public function saveToken(string $issuer, string $resource, AccessToken $token):
});
}

public function savePendingAuthorization(PendingAuthorization $authorization): void
{
$this->update(static function (array $data) use ($authorization): array {
foreach ($data['pending'] as $state => $pending) {
if (($pending['expires_at'] ?? 0) <= time()) {
unset($data['pending'][$state]);
}
}

$data['pending'][$authorization->state] = $authorization->jsonSerialize();

return $data;
});
}

public function consumePendingAuthorization(string $state): ?PendingAuthorization
{
$pending = null;
$this->update(static function (array $data) use ($state, &$pending): array {
$pending = $data['pending'][$state] ?? null;
unset($data['pending'][$state]);

return $data;
});

if (!\is_array($pending)) {
return null;
}

try {
return PendingAuthorization::fromArray($pending);
} catch (InvalidArgumentException) {
return null;
}
}

public function forget(?string $issuer = null): void
{
$this->update(static function (array $data) use ($issuer): array {
if (null === $issuer) {
return ['registrations' => [], 'tokens' => []];
return ['registrations' => [], 'tokens' => [], 'pending' => []];
}

unset($data['registrations'][$issuer], $data['tokens'][$issuer]);

foreach ($data['pending'] as $state => $pending) {
if (($pending['metadata']['issuer'] ?? null) === $issuer) {
unset($data['pending'][$state]);
}
}

return $data;
});
}
Expand All @@ -94,32 +136,33 @@ public function synchronize(callable $operation): mixed
}

/**
* @return array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>}
* @return array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>, pending: array<string, array<string, mixed>>}
*/
private function read(): array
{
if (!is_file($this->path)) {
return ['registrations' => [], 'tokens' => []];
return ['registrations' => [], 'tokens' => [], 'pending' => []];
}

$contents = file_get_contents($this->path);
$decoded = false === $contents ? null : json_decode($contents, true);

if (!\is_array($decoded)) {
// Not worth failing a connection over: the worst case is one more authorization.
return ['registrations' => [], 'tokens' => []];
return ['registrations' => [], 'tokens' => [], 'pending' => []];
}

return [
'registrations' => \is_array($decoded['registrations'] ?? null) ? $decoded['registrations'] : [],
'tokens' => \is_array($decoded['tokens'] ?? null) ? array_filter($decoded['tokens'], 'is_array') : [],
'pending' => \is_array($decoded['pending'] ?? null) ? array_filter($decoded['pending'], 'is_array') : [],
];
}

/**
* Applies a change under an exclusive lock, so another process can't overwrite it.
*
* @param callable(array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>}): array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>} $change
* @param callable(array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>, pending: array<string, array<string, mixed>>}): array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>, pending: array<string, array<string, mixed>>} $change
*/
private function update(callable $change): void
{
Expand Down Expand Up @@ -164,7 +207,7 @@ private function underLock(string $suffix, callable $operation): mixed
}

/**
* @param array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>} $data
* @param array{registrations: array<string, array<string, mixed>>, tokens: array<string, array<string, array<string, mixed>>>, pending: array<string, array<string, mixed>>} $data
*/
private function write(array $data): void
{
Expand Down
31 changes: 30 additions & 1 deletion src/Client/Auth/InMemoryCredentialStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,36 @@
*
* @author Christopher Hertel <[email protected]>
*/
final class InMemoryCredentialStorage implements CredentialStorageInterface
final class InMemoryCredentialStorage implements PendingAuthorizationStorageInterface
{
/** @var array<string, ClientRegistration> */
private array $registrations = [];

/** @var array<string, array<string, AccessToken>> */
private array $tokens = [];

/** @var array<string, PendingAuthorization> */
private array $pending = [];

public function savePendingAuthorization(PendingAuthorization $authorization): void
{
foreach ($this->pending as $state => $pending) {
if ($pending->expiresAt <= time()) {
unset($this->pending[$state]);
}
}

$this->pending[$authorization->state] = $authorization;
}

public function consumePendingAuthorization(string $state): ?PendingAuthorization
{
$pending = $this->pending[$state] ?? null;
unset($this->pending[$state]);

return $pending;
}

public function getClientRegistration(string $issuer): ?ClientRegistration
{
return $this->registrations[$issuer] ?? null;
Expand All @@ -49,11 +71,18 @@ public function forget(?string $issuer = null): void
if (null === $issuer) {
$this->registrations = [];
$this->tokens = [];
$this->pending = [];

return;
}

unset($this->registrations[$issuer], $this->tokens[$issuer]);

foreach ($this->pending as $state => $pending) {
if ($pending->metadata->issuer === $issuer) {
unset($this->pending[$state]);
}
}
}

public function synchronize(callable $operation): mixed
Expand Down
5 changes: 5 additions & 0 deletions src/Client/Auth/OAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ public function setLogger(LoggerInterface $logger): self

public function build(): OAuthAuthenticator
{
// The pending authorization would be lost with the request that started it.
if ($this->authorizationHandler instanceof RedirectAuthorizationHandler && null === $this->storage) {
throw new InvalidArgumentException('RedirectAuthorizationHandler finishes the authorization in a later request, so it needs a persistent storage. Call OAuth::setCredentialStorage().');
}

$httpClient = null === $this->httpClient ? NoRedirects::discover() : NoRedirects::apply($this->httpClient);
$requestFactory = $this->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
$streamFactory = $this->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
Expand Down
Loading
Loading