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 @@ -84,6 +84,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* [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.
* 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::setClientMetadataUrl()`, to use a client ID metadata document where the authorization server supports it, and `ConsoleAuthorizationHandler` for a shell without a browser.
* 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.
Expand Down
15 changes: 14 additions & 1 deletion docs/client/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ $auth = OAuth::forApplication('My App')
->build();
```

The SDK contains two handlers:
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. 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.
- `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 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:
Expand Down Expand Up @@ -104,6 +105,18 @@ The MCP server names its authorization server. Without an expected issuer, a hos

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. It can't contain a fragment, credentials or dot segments:

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

The document must be a JSON object whose `client_id` is exactly this URL. It must also contain `client_name` and `redirect_uris`, and `redirect_uris` must list the redirect URI of the client (by default `http://127.0.0.1:8765/callback`). The authorization server must be able to fetch the document.

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.

## 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:
Expand Down
40 changes: 40 additions & 0 deletions src/Client/Auth/AuthorizationCallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Client\Auth;

/**
* Reads the parameters of an authorization response from the query of the redirect URI.
*
* @internal
*
* @author Christopher Hertel <[email protected]>
*/
final class AuthorizationCallback
{
/**
* @return array<string, string>
*/
public static function parameters(string $query): array
{
parse_str($query, $parameters);

$callback = [];

foreach ($parameters as $name => $value) {
if (\is_string($name) && \is_scalar($value)) {
$callback[$name] = (string) $value;
}
}

return $callback;
}
}
61 changes: 61 additions & 0 deletions src/Client/Auth/ConsoleAuthorizationHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Client\Auth;

use Mcp\Exception\AuthorizationException;

/**
* Prints the authorization URL and reads the redirected URL back, for a shell without a browser.
*
* @author Christopher Hertel <[email protected]>
*/
final class ConsoleAuthorizationHandler implements AuthorizationHandlerInterface
{
/**
* @param resource $input stream the redirected URL is read from
* @param resource $output stream the prompt is written to
*/
public function __construct(
private readonly mixed $input = \STDIN,
private readonly mixed $output = \STDERR,
) {
}

public function authorize(string $authorizationUrl, string $redirectUri): array
{
fwrite($this->output, \sprintf(
"\nOpen this URL to authorize the application:\n\n %s\n\nThen paste the full URL you were redirected to: ",
$authorizationUrl,
));

$answer = fgets($this->input);

if (false === $answer || '' === trim($answer)) {
throw new AuthorizationException('No authorization response was provided.');
}

$answer = trim($answer);
$query = parse_url($answer, \PHP_URL_QUERY);

if (!\is_string($query) || '' === $query) {
throw new AuthorizationException('Paste the whole URL you were redirected to, not just the code: the rest of it is what proves the response belongs to this request.');
}

$callback = AuthorizationCallback::parameters($query);

if (!isset($callback['code']) && !isset($callback['error'])) {
throw new AuthorizationException('The pasted URL carries neither a code nor an error. Paste the URL you were redirected to after signing in, not the one you opened.');
}

return $callback;
}
}
10 changes: 1 addition & 9 deletions src/Client/Auth/HeadlessAuthorizationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,7 @@ public function authorize(string $authorizationUrl, string $redirectUri): array
throw new AuthorizationException(\sprintf('The authorization server redirected to "%s", which carries no query parameters.', $location));
}

parse_str($query, $parameters);

$callback = [];

foreach ($parameters as $name => $value) {
if (\is_string($name) && \is_scalar($value)) {
$callback[$name] = (string) $value;
}
}
$callback = AuthorizationCallback::parameters($query);

// Only the names, as the query carries the authorization code.
$this->logger->debug('Authorization endpoint redirected', ['parameters' => array_keys($callback)]);
Expand Down
10 changes: 1 addition & 9 deletions src/Client/Auth/LoopbackAuthorizationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,7 @@ private function serve($connection, string $callbackPath, ?string $state): ?arra
}

$target = parse_url($matches[1]);
parse_str(\is_string($target['query'] ?? null) ? $target['query'] : '', $parameters);

$callback = [];

foreach ($parameters as $name => $value) {
if (\is_string($name) && \is_scalar($value)) {
$callback[$name] = (string) $value;
}
}
$callback = AuthorizationCallback::parameters(\is_string($target['query'] ?? null) ? $target['query'] : '');

$isCallback = [] !== $callback
&& $callbackPath === ($target['path'] ?? null)
Expand Down
29 changes: 29 additions & 0 deletions src/Client/Auth/OAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Http\Discovery\Psr17FactoryDiscovery;
use Mcp\Client\Transport\NoRedirects;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Server\Transport\Http\OAuth\SecureUrl;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
Expand All @@ -36,6 +37,7 @@ final class OAuth
private ?string $clientId = null;
private ?string $clientSecret = null;
private ?string $expectedIssuer = null;
private ?string $clientMetadataUrl = null;
private string $applicationType = 'native';
private ?string $clientUri = null;
private ?string $softwareId = null;
Expand Down Expand Up @@ -103,6 +105,32 @@ public function setExpectedIssuer(string $issuer): self
return $this;
}

/**
* Use the URL of a client ID metadata document as the client id where the server supports it.
*/
public function setClientMetadataUrl(string $url): self
{
try {
$parts = SecureUrl::parse($url, 'client metadata URL');
} catch (InvalidArgumentException) {
$parts = null;
}

if (null === $parts
|| false === filter_var($url, \FILTER_VALIDATE_URL)
|| 'https' !== strtolower($parts['scheme'])
|| '' === trim($parts['path'] ?? '', '/')
|| isset($parts['fragment']) || isset($parts['user']) || isset($parts['pass'])
|| [] !== array_intersect(explode('/', rawurldecode($parts['path'] ?? '')), ['.', '..'])
) {
throw new InvalidArgumentException(\sprintf('The client metadata URL must be an https URL with a path, and without a fragment, credentials or dot segments, got "%s".', $url));
}

$this->clientMetadataUrl = $url;

return $this;
}

/**
* Request these scopes instead of those the server asks for; a step-up challenge still adds its own.
*/
Expand Down Expand Up @@ -221,6 +249,7 @@ public function build(): OAuthAuthenticator
$this->redirectUri,
$this->clientId,
$this->clientSecret,
$this->clientMetadataUrl,
$this->scopes,
$this->offlineAccess,
$this->legacyDiscovery,
Expand Down
10 changes: 10 additions & 0 deletions src/Client/Auth/OAuthAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re
try {
$token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $request);
} catch (OAuthErrorException $e) {
if ($client->clientId === $this->configuration->clientMetadataUrl) {
throw new OAuthErrorException(\sprintf('The authorization server "%s" answered invalid_client for the client metadata document "%s". Check that it can fetch the document and that its "client_id" and "redirect_uris" match.', $metadata->issuer, $client->clientId), $e->error);
}

if (!$client->dynamic) {
throw new OAuthErrorException(\sprintf('The authorization server "%s" answered invalid_client for the configured client "%s". Check the client ID and secret given to setClientCredentials().', $metadata->issuer, $client->clientId), $e->error);
}
Expand Down Expand Up @@ -513,6 +517,12 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco
);
}

if ($metadata->clientIdMetadataDocumentSupported && null !== $configuration->clientMetadataUrl) {
$this->logger->debug('Using the client metadata document URL as the client id', ['client_id' => $configuration->clientMetadataUrl]);

return new ClientRegistration($configuration->clientMetadataUrl, null, TokenEndpointAuthMethod::negotiate($metadata->tokenEndpointAuthMethodsSupported, false));
}

// Another process may register at the same time, and its tokens would not work with this client.
return $this->storage->synchronize(function () use ($metadata, $configuration, $scopes, $scopesSupported): ClientRegistration {
$stored = $this->storage->getClientRegistration($metadata->issuer);
Expand Down
21 changes: 12 additions & 9 deletions src/Client/Auth/OAuthConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,17 @@
final class OAuthConfiguration
{
/**
* @param ClientMetadata $clientMetadata what the client says about itself when registering
* @param string $redirectUri where the authorization server sends the user back
* @param ?string $clientId a client id issued out of band, skipping registration
* @param ?string $clientSecret the matching secret, when there is one
* @param string[] $scopes scopes to request regardless of what is advertised
* @param bool $offlineAccess whether to ask for a refresh token where the server offers one
* @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a
* server publishes no protected resource metadata
* @param ?string $expectedIssuer the only authorization server to authorize with
* @param ClientMetadata $clientMetadata what the client says about itself when registering
* @param string $redirectUri where the authorization server sends the user back
* @param ?string $clientId a client id issued out of band, skipping registration
* @param ?string $clientSecret the matching secret, when there is one
* @param ?string $clientMetadataUrl a URL serving the client metadata document, usable as a
* client id against servers that accept one
* @param string[] $scopes scopes to request regardless of what is advertised
* @param bool $offlineAccess whether to ask for a refresh token where the server offers one
* @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a
* server publishes no protected resource metadata
* @param ?string $expectedIssuer the only authorization server to authorize with
*
* @throws InvalidArgumentException when the redirect URI is insecure, or a client id is set without an expected issuer
*/
Expand All @@ -40,6 +42,7 @@ public function __construct(
public readonly string $redirectUri = OAuth::DEFAULT_REDIRECT_URI,
public readonly ?string $clientId = null,
public readonly ?string $clientSecret = null,
public readonly ?string $clientMetadataUrl = null,
public readonly array $scopes = [],
public readonly bool $offlineAccess = true,
public readonly bool $legacyDiscovery = false,
Expand Down
5 changes: 5 additions & 0 deletions tests/Conformance/client.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ function buildAuthenticator(string $url, string $scenario, array $context, Psr\L
$oauth->setLegacyDiscovery(true);
}

// The harness never fetches the document.
if ('auth/basic-cimd' === $scenario) {
$oauth->setClientMetadataUrl('https://conformance-test.local/client-metadata.json');
}

return $oauth->build();
}

Expand Down
1 change: 0 additions & 1 deletion tests/Conformance/conformance-baseline-2025-11-25.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
client:
# Elicitation defaults (SEP-1034) are not filled in when accepting.
- elicitation-sep1034-client-defaults
- auth/basic-cimd
- auth/client-credentials-jwt
- auth/client-credentials-basic
- auth/cross-app-access-complete-flow
1 change: 0 additions & 1 deletion tests/Conformance/conformance-baseline-2026-07-28.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
client:
- http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate
- auth/basic-cimd
Loading
Loading