Skip to content
Draft
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
26 changes: 26 additions & 0 deletions .github/workflows/pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,32 @@ jobs:
echo ""
done || echo "No results found"

e2e:
name: end-to-end / client authorization
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: "none"

- name: Install Composer
uses: "ramsey/composer-install@v4"

# Keycloak, a real MCP server, and a client that has to authorize its way in.
# See tests/E2E/README.md.
- name: Run the authorization end-to-end suite
# Kept up so the next step can read the containers' logs when it fails.
run: E2E_KEEP=1 tests/E2E/run.sh

- name: Show logs on failure
if: failure()
run: docker compose -f tests/E2E/docker-compose.yml logs

qa:
runs-on: ubuntu-latest
steps:
Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ vendor
examples/**/dev.log
examples/**/cache
examples/**/sessions
tests/Conformance/client-conformance.json
tests/Conformance/server-conformance.json
examples/client/.oauth-credentials.json
tests/Conformance/client-conformance*.json
tests/Conformance/server-conformance*.json
tests/Conformance/results*
tests/Conformance/sessions
tests/Conformance/logs/*.log
tests/E2E/sessions

# phpDocumentor
.phpdoc/build/
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ All notable changes to `mcp/sdk` will be documented in this file.

### Client

* 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).
* Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`.
* Add the `auth` parameter of `HttpTransport` and `AuthenticatorInterface`, to authenticate every request and retry the ones the server refuses. `BearerToken` sends a token you already have, and `AuthenticatingHttpClient` offers the same behavior as a PSR-18 decorator.
* [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.

0.9.0
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests interop-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve
.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests interop-tests e2e-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve

# The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has
# none of them. Pinned to the same version CI runs (see
Expand Down Expand Up @@ -36,6 +36,11 @@ interop-tests:
npm ci --prefix tests/Interop --no-audit --no-fund
vendor/bin/phpunit --testsuite=interop

# Client authorization against a real Keycloak and a real MCP server, over Docker.
# See tests/E2E/README.md; not part of `make ci`, since it needs Docker.
e2e-tests:
tests/E2E/run.sh

conformance-tests: conformance-server conformance-client

conformance-server:
Expand Down
165 changes: 165 additions & 0 deletions docs/client/authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Authorization

A remote MCP server can require an OAuth access token. It answers a request without a token with `401 Unauthorized`. Pass an authenticator to the HTTP transport and the client gets a token and sends the request again:

```php
use Mcp\Client;
use Mcp\Client\Auth\OAuth;
use Mcp\Client\Transport\HttpTransport;

$client = Client::builder()->setClientInfo('My App', '1.0.0')->build();

$client->connect(new HttpTransport(
'https://mcp.example.com/mcp',
auth: OAuth::forApplication('My App')->build(),
));
```

The client does nothing until a server answers with `401` or `403`. It talks to a server that doesn't require a token in the same way as a client without `auth`.

If you already have a token, see [Sending an access token](transports.md#sending-an-access-token).

## What the client does

When the server refuses a request, the client takes these steps:

| Step | Specification |
| --- | --- |
| Read the metadata URL and the required scopes from `WWW-Authenticate` | [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) |
| Fetch the protected resource metadata and check that it describes this server | [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) |
| Fetch the metadata of the authorization server and check its issuer | [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) |
| Register the client, unless you configured a client or the storage has one | [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) |
| Run the authorization code flow with PKCE | [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749), [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) |
| Check `state` and `iss` in the authorization response | [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) |
| Send the `resource` parameter in the authorization and token requests | [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) |
| Send the token, and refresh it or authorize again when it stops working | [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) |

## Sending the User to the Browser

An authorization handler sends the user to the authorization server and returns the answer. Pass one with `setAuthorizationHandler()`:

```php
use Mcp\Client\Auth\HeadlessAuthorizationHandler;
use Mcp\Client\Auth\OAuth;

$auth = OAuth::forApplication('My App')
->setAuthorizationHandler(new HeadlessAuthorizationHandler())
->build();
```

The SDK contains two handlers:

- `LoopbackAuthorizationHandler` is the default. It opens the system browser and listens on the port of the redirect URI until the browser returns. Use it for desktop and command line applications.
- `HeadlessAuthorizationHandler` requests the authorization URL itself and reads the code from the redirect. It only works if the authorization server grants access without asking the user (e.g. a test server).

For anything else, implement `AuthorizationHandlerInterface`. It has one method:

```php
use Mcp\Client\Auth\AuthorizationHandlerInterface;

final class AcmeAuthorizationHandler implements AuthorizationHandlerInterface
{
public function authorize(string $authorizationUrl, string $redirectUri): array
{
// send the user to $authorizationUrl and wait for the redirect to $redirectUri

// return its query parameters, the SDK checks them
return ['code' => '...', 'state' => '...', 'iss' => '...'];
}
}
```

The default redirect URI is `http://127.0.0.1:8765/callback`. Change it with `setRedirectUri()` if the port is in use or if the authorization server expects another one. The loopback handler listens on the host and port of this URI.

## Storing Credentials

By default, the client keeps its tokens in memory. A command line application then sends the user to the browser on every run. Use `FileCredentialStorage` to keep them between runs:

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

$auth = OAuth::forApplication('My App')
->setCredentialStorage(new FileCredentialStorage($_SERVER['HOME'].'/.config/my-app/credentials.json'))
->build();
```

The file contains tokens and client secrets. The SDK creates it with the mode `0600` in a directory with the mode `0700`, and doesn't encrypt it. To use a keychain or a secrets manager, implement `CredentialStorageInterface`.

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.

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

```php
// the secret is optional
OAuth::forApplication('My App')->setClientCredentials('my-client-id', 'my-client-secret');
```

The client reads from the metadata of the authorization server how to send the secret.

## Choosing Scopes

By default, the client requests the scopes of the challenge. If there are none, it requests all scopes in `scopes_supported` of the resource. If there are none either, it sends no `scope` parameter. Use `setScopes()` to request your own:

```php
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.

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)` if you don't want a refresh token.

## Connecting to Older Servers

Since the `2025-06-18` revision, a protected server must publish protected resource metadata. By default, the client refuses a server without it. To connect to a server built for the `2025-03-26` revision, call `setLegacyDiscovery(true)`.

## Authenticating Other Requests

To send other requests to the same server with the same credentials, wrap your PSR-18 client:

```php
use Mcp\Client\Auth\AuthenticatingHttpClient;

$httpClient = new AuthenticatingHttpClient($yourPsr18Client, $auth);
```

It authorizes at most 3 times per request. If the request body isn't seekable, it doesn't send the request again and returns the rejected response.

## What the Client Refuses

The client treats the MCP server and its metadata as untrusted input. It stops with an `AuthorizationException` in these cases:

| Input | Reason |
| --- | --- |
| The protected resource metadata names a `resource` that doesn't cover 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 |
| The authorization response has no `state`, or another `state` | It doesn't answer the request of this client |
| `iss` doesn't match the issuer, or is missing although the server announced it | The response may come from another server |
| The server has no protected resource metadata, without `setLegacyDiscovery(true)` | There is nothing to check the authorization server against |

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.

!!! warning
The client can't protect a user who approves a hostile authorization server. A hostile MCP server can name one, and the user sees its address in the browser. Tell your users to check it before they sign in.

## Handling Errors

`connect()` reports a failed authorization as a `ConnectionException`. The cause is an `AuthorizationException`:

```php
use Mcp\Exception\AuthorizationException;
use Mcp\Exception\ConnectionException;

try {
$client->connect($transport);
} catch (ConnectionException $e) {
if ($e->getPrevious() instanceof AuthorizationException) {
echo "Could not authorize: {$e->getPrevious()->getMessage()}\n";
}
}
```

After `connect()`, requests throw the `AuthorizationException` directly. The client doesn't retry a failed authorization. Pass a PSR-3 logger with `setLogger()` to see each step.
19 changes: 18 additions & 1 deletion docs/client/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ all client errors in one place. All classes below live in the `Mcp\Exception` na
| Exception | Thrown when |
| --- | --- |
| `ConnectionException` | `connect()` fails on every attempt, an HTTP request fails on the network level, or you call a method before `connect()` |
| `RequestException` | the server answers with an error, the request reaches the default request timeout (code `-32603`, message `Request timed out`), the server answers with an HTTP error status, or the STDIO server process exits while you wait for an answer |
| `RequestException` | the server answers with an error, the request reaches the default request timeout (code `-32603`, message `Request timed out`), the server answers with an HTTP error status other than `401` or `403`, or the STDIO server process exits while you wait for an answer |
| `AuthorizationException` | the server answers a request, a notification or the resumption of an event stream with the HTTP status `401` or `403`, or the [OAuth flow](authorization.md) fails |
| `TimeoutException` | the `timeoutSeconds` deadline of a `callTool()` call expires |
| `RequestCancelledException` | the cancellation token of a `callTool()` call is canceled, see [Cancellation and deadlines](transports.md#cancellation-and-deadlines) |
| `InvalidArgumentException` | `callTool()` gets a `timeoutSeconds` that is not finite and positive, `readResource()` gets an empty URI, the builder gets an invalid timeout, retry count or fallback revision, the request can't be encoded as JSON (for example invalid UTF-8 in tool arguments), or the server returns a malformed result (also from `connect()`) |
Expand All @@ -27,6 +28,22 @@ try {
}
```

## AuthorizationException

This exception also reports a failed [authorization](authorization.md) flow. The message of a refused request contains the `WWW-Authenticate` header of the response, if the server sends one:

```php
use Mcp\Exception\AuthorizationException;

try {
$result = $client->callTool('get_weather', ['city' => 'Paris']);
} catch (AuthorizationException $e) {
echo "Not authorized: {$e->getMessage()}\n";
}
```

During `connect()`, the client doesn't retry the request. It throws a `ConnectionException`, and `$e->getPrevious()` returns the `AuthorizationException`.

## RequestException

```php
Expand Down
2 changes: 2 additions & 0 deletions docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ $client->disconnect();
and what the server told you about itself when connecting.
* **[Transports](transports.md)** — launching a local server process (STDIO) or talking
to a remote one (HTTP).
* **[Authorization](authorization.md)** — connecting to a server that wants an OAuth
token, and how the client goes and gets one.
* **[Tools, resources & prompts](capabilities.md)** — listing and calling everything a
server exposes, including progress callbacks and completions.
* **[Server-initiated requests](server-requests.md)** — the other direction: log
Expand Down
23 changes: 21 additions & 2 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ use Mcp\Client\Transport\HttpTransport;

$transport = new HttpTransport(
endpoint: 'http://localhost:8000',
headers: ['Authorization' => 'Bearer token'],
headers: ['X-Tenant' => 'acme'],
);
```

Expand All @@ -54,6 +54,7 @@ $transport = new HttpTransport(
- `listen` (bool): Open the server's listening stream after the handshake (see below)
- `reconnectPolicy` (ReconnectPolicy|null): How an interrupted SSE stream is resumed (default: 5 attempts, 1–10 s backoff)
- `clock` (ClockInterface|null): PSR-20 clock used to schedule reconnects (default: a monotonic clock)
- `auth` (AuthenticatorInterface|null): Credentials for a server that requires authorization, see [Authorization](authorization.md)

When an SSE connection closes before its JSON-RPC response arrives, the transport resumes it with a GET request carrying the latest `Last-Event-ID`. A server-provided SSE `retry` value controls the delay; otherwise the fallback delay doubles from 1 second up to 10 seconds. Completed requests and explicitly closed transports are not reconnected. A server-provided `retry` value is not capped, so a long one leaves the request waiting until its timeout. A reconnect answered with a redirect or a client error (e.g. `404` for an expired session or `405` without GET support) fails the request at once; only `408`, `429`, server errors and a successful answer that is not an event stream are retried. Protocol version `2026-07-28` removed stream resumption, so there an interrupted stream fails the request at once.

Expand Down Expand Up @@ -86,7 +87,7 @@ composer require php-http/guzzle7-adapter

**Error status codes:**

If the server answers with an HTTP error status, the request fails at once with a `RequestException`.
If the server answers with an HTTP error status, the request fails at once with a `RequestException`. The status `401` or `403` fails with an [`AuthorizationException`](errors.md#authorizationexception).

**Session ID:**

Expand Down Expand Up @@ -120,6 +121,24 @@ $transport = new HttpTransport('http://localhost:8000', listen: true);
- A server without a listening stream answers `405`, and the connection carries on without one.
- On `2026-07-28`, which has no standalone stream, the option does nothing.

### Sending an access token

If you already have an access token (e.g. from a secrets manager), pass it with the `auth` parameter. To get a token through OAuth, see [Authorization](authorization.md).

```php
use Mcp\Client\Auth\BearerToken;
use Mcp\Client\Transport\HttpTransport;

$transport = new HttpTransport(
'https://mcp.example.com/mcp',
auth: new BearerToken($accessToken),
);
```

The transport sends the token in the `Authorization` header of every request. This value replaces an `Authorization` header that you pass with `headers`.

To get credentials in another way, implement `Mcp\Client\Auth\AuthenticatorInterface`. The transport calls `authenticate()` for every request. It calls `handleChallenge()` when the server answers with `401` or `403`. Return `true` to send the request again, at most 3 times, or `false` to give up.

## Cancellation and deadlines

`callTool()` accepts optional `cancellation: ?CancellationTokenInterface` and
Expand Down
1 change: 1 addition & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser
| [`http_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_client_communication.php) | The same handlers over the Streamable HTTP transport | [Server-initiated requests](client/server-requests.md) |
| [`stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) | Answering elicitation requests from an interactive prompt | [Server-initiated requests](client/server-requests.md) |
| [`stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) | Exposing workspace roots and signalling `roots/list_changed` | [Server-initiated requests](client/server-requests.md) |
| [`oauth_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/oauth_client.php) | Authorizing against a protected server, with discovery, PKCE and the browser | [Authorization](client/authorization.md) |
| [`stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) | A client that only connects on revision `2026-07-28`, see [Modern-era client](#modern-era-client) | [Connecting to a server](client/connecting.md) |

Client examples run directly:
Expand Down
Loading
Loading