Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
118922c
[Client] Add OAuth authorization with the authorization code flow
chr-hertel Nov 8, 2026
dd65025
[Docs] Explain that a parent resource covers the paths below it
chr-hertel Nov 8, 2026
642236a
[Client] Refuse an authorization server that does not list S256 for PKCE
chr-hertel Nov 8, 2026
469c551
[Client] Turn off redirects wherever a token or client secret is sent
chr-hertel Nov 8, 2026
8a29ba8
[Client] Only assume the fixed legacy endpoints after a 404
chr-hertel Nov 8, 2026
f17d11c
[Client] Say "no usable" when protected resource metadata was rejected
chr-hertel Nov 8, 2026
7ee9e5c
[Client] Only authorize again after a 403 with insufficient_scope
chr-hertel Nov 8, 2026
f2f062d
[Client] Keep a token bound to its resource when authorization fails
chr-hertel Nov 8, 2026
12aff2a
[Client] Always show the authorization URL
chr-hertel Nov 8, 2026
4450fad
[Client] Keep the authorization URL intact when opening the browser o…
chr-hertel Nov 8, 2026
d14d8e0
[Client] Only accept the loopback callback of the current authorizati…
chr-hertel Nov 8, 2026
4ade3f6
[Client] Choose the expected issuer wherever the resource lists it
chr-hertel Nov 8, 2026
c6198c3
[Client] Only record scopes a refresh actually granted
chr-hertel Nov 8, 2026
cad838e
[Client] Treat a registration without a secret as a public client
chr-hertel Nov 8, 2026
ecc4f05
[Client] Register again when a stored registration can no longer work
chr-hertel Nov 8, 2026
184c4b6
[Client] Limit the size of the responses read during authorization
chr-hertel Nov 8, 2026
3668fac
[Client] Make the credential file and the busy port tests hold on Win…
chr-hertel Nov 8, 2026
a7427bb
[Client] Keep the comments of the authorization classes to one line
chr-hertel Nov 8, 2026
632d74b
[Client] Give up offline_access based on the OAuth error code
chr-hertel Nov 8, 2026
fcfc2dd
[Client] Refuse an authorization server without PKCE before registering
chr-hertel Nov 8, 2026
dc064cc
[Client] Keep a scope or an authorization code of "0"
chr-hertel Nov 8, 2026
3b14622
[Client] Save credentials over an entry of the wrong type
chr-hertel Nov 8, 2026
685f102
[Client] Try a refresh token when the server does not list its grants
chr-hertel Nov 8, 2026
c572c97
[Client] Ignore the lock file of the OAuth example
chr-hertel Nov 8, 2026
417d6b0
[Client] Remove what nothing uses from ClientMetadata
chr-hertel Nov 8, 2026
07d7ee7
[Client] Send a token until it has really expired
chr-hertel Nov 8, 2026
2e5b11e
[Client] Keep the scopes of a stored token in a new authorization
chr-hertel Nov 8, 2026
db9fb1e
[Client] Not carry requested scopes over to another resource
chr-hertel Nov 8, 2026
0f8e14a
[Client] Check the issuer of an authorization error
chr-hertel Nov 8, 2026
b5d4e00
[Client] Require an expected issuer for any configured client
chr-hertel Nov 8, 2026
b49775b
[Client] Only keep the scopes of a stored token in a step-up
chr-hertel Nov 9, 2026
fa6fcbe
[Client] Treat a secret expiry of 0 as no expiry, however it was created
chr-hertel Nov 9, 2026
40ebce7
[Client] Limit the size and time of the headers the headless handler …
chr-hertel Nov 9, 2026
54f74c6
[Client] Refresh a token one process at a time
chr-hertel Nov 9, 2026
98cc693
[Client] Store a token by the server it may be sent to
chr-hertel Nov 9, 2026
c93f950
[Client] Refuse a redirect URI without https outside of loopback
chr-hertel Nov 9, 2026
f138d84
[Client] Remove OAuth::setResource()
chr-hertel Nov 9, 2026
a5890bf
[Client] Refuse loopback targets named by a remote server
chr-hertel Nov 9, 2026
e87c1a9
[Client] Report any failure of a challenge as an authorization failure
chr-hertel Nov 9, 2026
2df487d
[Client] Refuse a token that does not fit in a header and ignore an u…
chr-hertel Nov 9, 2026
bca1dba
[Client] Only accept and send Bearer tokens
chr-hertel Nov 9, 2026
820f79f
[Client] Ignore an auth method that is not a string
chr-hertel Nov 9, 2026
9f5cd9a
[Client] Only request offline_access when it is enabled and supported
chr-hertel Nov 9, 2026
ed168c8
[Client] Not authorize again for scopes it already requested
chr-hertel Nov 9, 2026
1f6e50b
[Client] Keep the refresh token through an outage of the token endpoint
chr-hertel Nov 9, 2026
be3bd3a
[Client] Not start a sign-in to close a session or open the listening…
chr-hertel Nov 9, 2026
5da0bce
[Client] Refuse a redirect URI the loopback handler cannot serve
chr-hertel Nov 9, 2026
871dc56
[Client] Remove what only tests use from Pkce
chr-hertel Nov 9, 2026
e52b7b6
[Client] Test that the expected issuer allows a loopback authorizatio…
chr-hertel Nov 9, 2026
762d39a
[Client] Refuse an unidentified authorization endpoint on another origin
chr-hertel Nov 9, 2026
be94207
[Client] Remove control characters from authorization errors
chr-hertel Nov 9, 2026
626d081
[Client] Document that a passed-in HTTP client must not follow redirects
chr-hertel Nov 9, 2026
e729564
[Client] Ask for the resource scopes on an insufficient_scope without…
chr-hertel Nov 9, 2026
2a5968b
[Client] Treat an explicit default port as the same origin
chr-hertel Nov 9, 2026
03dd52a
[Client] Register the scopes the resource may ask for later
chr-hertel Nov 9, 2026
a0cd0f5
[Client] Send the request of the headless handler with an HTTP client
chr-hertel Nov 9, 2026
c936c3a
[Client] Register a client under the shared lock
chr-hertel Nov 9, 2026
59d9622
[Client] Register again when a step-up goes beyond the registered scopes
chr-hertel Nov 9, 2026
45c1d7b
[Client] Refuse a redirect URI with a fragment or userinfo
chr-hertel Nov 9, 2026
b394ff8
[Client] Fail fast when the token endpoint refuses a configured client
chr-hertel Nov 9, 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
26 changes: 26 additions & 0 deletions .github/workflows/pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,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
8 changes: 6 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ vendor
examples/**/dev.log
examples/**/cache
examples/**/sessions
tests/Conformance/client-conformance.json
tests/Conformance/server-conformance.json
examples/client/.oauth-credentials.json
examples/client/..oauth-credentials.json.lock
examples/client/..oauth-credentials.json.refresh.lock
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 @@ -78,6 +78,10 @@ All notable changes to `mcp/sdk` will be documented in this file.
* [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.
* 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 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.
* 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 `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
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
185 changes: 185 additions & 0 deletions docs/client/authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# 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. It also writes the URL to `STDERR`, so you can open it yourself if no browser starts. Use it for desktop and command line applications.
- `HeadlessAuthorizationHandler` requests the authorization URL with an HTTP client 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 URI must use `https` or a loopback host (e.g. `127.0.0.1`), and must not have a fragment or userinfo. Otherwise, `build()` throws an `InvalidArgumentException`. The loopback handler listens on the host and port of this URI, so it only works with `http` on a loopback host. For an `https` URI, implement your own handler.

## 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. Windows ignores these modes, so store the file in a directory that only you can read (e.g. your user profile). The SDK also creates two lock files next to it, so processes can share the file and only one of them refreshes a token at a time. To use a keychain or a secrets manager, implement `CredentialStorageInterface`. Its `synchronize()` method must run one operation at a time across all processes that share the storage.

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.

## 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
OAuth::forApplication('My App')
// the secret is optional
->setClientCredentials('my-client-id', 'my-client-secret')
// required
->setExpectedIssuer('https://auth.example.com');
```

The MCP server names its authorization server. Without an expected issuer, a hostile MCP server could name its own and receive your secret. It could also send the user to your authorization server with your client ID and receive the authorization code. Pass the `issuer` value from the metadata of your authorization server. If you set a client ID without an expected issuer, `build()` throws an `InvalidArgumentException`.

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

## 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);
```

The token is only added to requests for this server, but a redirect receives the same headers. This class and `OAuth` turn off redirects on a `symfony/http-client` client. Before `symfony/http-client` 6.2, create that client with `max_redirects` set to `0`. Any other client *must not* follow redirects.

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 |
| 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 |
| 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 |
| `iss` doesn't match the issuer, or is missing although the server announced it | The response may come from another server |
| The token response has another `token_type` than `Bearer` | The client can only send bearer tokens |
| The authorization server has another issuer than `setExpectedIssuer()` | It would receive the client's credentials |
| 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 |

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.

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.

A `resource` can name a parent path of the MCP server. All paths below it then belong to the resource, and the client can send the token to them. If several servers share a host, give each one its own host (e.g. `tenant-a.example.com`).

!!! 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. 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.
4 changes: 2 additions & 2 deletions docs/client/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ all client errors in one place. All classes below live in the `Mcp\Exception` na
| --- | --- |
| `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 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` |
| `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 @@ -30,7 +30,7 @@ try {

## AuthorizationException

The message contains the `WWW-Authenticate` header of the response, if the server sends one:
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;
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