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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ 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 `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.
* 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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
},
"suggest": {
"firebase/php-jwt": "Required for the JwtTokenValidator.",
"firebase/php-jwt": "Required for the JwtTokenValidator, and for private_key_jwt client authentication in the OAuth client.",
"psr/cache-implementation": "Required for JwtTokenValidator::fromIssuer() to cache metadata and keys.",
"symfony/finder": "Required for file-based discovery.",
"symfony/lock": "Required for the SymfonyLockSessionLock."
Expand Down
222 changes: 222 additions & 0 deletions docs/client/authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# 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 three 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.
- `ConsoleAuthorizationHandler` prints the authorization URL and reads the URL of the redirect from the standard input. Use it in a remote shell or a container without a browser. The user must paste the whole URL, because the SDK checks its `state` and `iss`.
- `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.

## Using a URL as the Client ID

If you publish a client ID metadata document, the authorization server can read your client's metadata from its URL. You then don't need a registration per server. The URL must use `https` and have a path:

```php
OAuth::forApplication('My App')->setClientMetadataUrl('https://app.example.com/client-metadata.json');
```

The client only uses this URL if the authorization server supports these documents. Otherwise, it registers. A client ID that you pass with `setClientCredentials()` takes precedence.

## Running Without a User

A background job has no user who can approve a request. Use `forServiceAccount()` with a client ID and a secret that an administrator created for you:

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

$auth = OAuth::forServiceAccount('Acme Report Job', 'my-client-id', 'my-client-secret')->build();
```

The client then authenticates as itself with the client credentials grant. It doesn't open a browser and doesn't register. If you pass no secret, you must call `setPrivateKeyJwt()`.

## Authenticating With a Private Key

An authorization server can accept a signed assertion in place of a shared secret. This needs the `firebase/php-jwt` package:

```bash
composer require firebase/php-jwt
```

Pass your key with `setPrivateKeyJwt()`:

```php
// the default algorithm is RS256
OAuth::forServiceAccount('Acme Report Job', 'my-client-id')
->setPrivateKeyJwt(file_get_contents('/etc/acme/private-key.pem'), 'ES256');
```

The algorithm can be `RS256`, `RS384`, `RS512`, `PS256`, `ES256`, `ES256K`, `ES384` or `EdDSA`. Use one that the authorization server lists in `token_endpoint_auth_signing_alg_values_supported`. `PS256` also needs `phpseclib/phpseclib` and `EdDSA` needs the `sodium` extension. You can also use a key for a user-facing client, together with `setClientCredentials()`.

## Using an Enterprise Identity

If your users already signed in at a company identity provider, they don't need to approve each MCP server. With cross-app access, the client exchanges their identity token for a grant for one MCP server. It then gets the access token with this grant:

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

$auth = OAuth::forApplication('My App')
->setClientCredentials('my-client-id', 'my-client-secret')
->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', $identityToken))
->build();
```

Your application gets `$identityToken` from its own sign-in. The token endpoint of the identity provider must use `https`, unless it runs on a loopback address. If the client has another ID at the identity provider, pass it as the `clientId` argument of `CrossAppAccess`.

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