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 @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file.

* [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument.
* Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions.
* Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0).

0.8.0
-----
Expand Down
8 changes: 5 additions & 3 deletions src/Server/Transport/Http/OAuth/OidcDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@ public function discover(string $issuer): array
*/
private function fetchMetadata(string $issuer): array
{
$issuer = rtrim($issuer, '/');
$parsed = parse_url($issuer);
// The trailing slash is dropped to build discovery URLs (RFC 8414 §3.1),
// but the issuer is matched verbatim (RFC 8414 §3.3, OIDC Discovery §4.3).
$discoveryIssuer = rtrim($issuer, '/');
$parsed = parse_url($discoveryIssuer);

if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) {
throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer));
Expand All @@ -170,7 +172,7 @@ private function fetchMetadata(string $issuer): array
// 2. OIDC path insertion
$discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path;
// 3. OIDC path appending
$discoveryUrls[] = $issuer.'/.well-known/openid-configuration';
$discoveryUrls[] = $discoveryIssuer.'/.well-known/openid-configuration';
} else {
// For issuer URLs without path components
$discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server';
Expand Down
82 changes: 82 additions & 0 deletions tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,88 @@ public function testIssuerWithoutPathUsesStandardWellKnownEndpoints(): void
$this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]);
}

#[TestDox('issuer with path and trailing slash is discovered and matched verbatim')]
public function testIssuerWithPathAndTrailingSlash(): void
{
$this->skipIfPsrHttpClientIsMissing();

$factory = new Psr17Factory();
$requestedUrls = [];
$issuer = 'https://auth.example.com/application/o/mcp/';
$validMetadata = [
'issuer' => $issuer,
'authorization_endpoint' => 'https://auth.example.com/application/o/authorize/',
'token_endpoint' => 'https://auth.example.com/application/o/token/',
'jwks_uri' => 'https://auth.example.com/application/o/mcp/jwks/',
'code_challenge_methods_supported' => ['S256'],
];

$httpClient = $this->createMock(ClientInterface::class);
$httpClient->expects($this->exactly(3))
->method('sendRequest')
->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface {
$requestedUrls[] = (string) $request->getUri();

if (3 !== \count($requestedUrls)) {
return $factory->createResponse(404);
}

return $factory->createResponse(200)->withBody(
$factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)),
);
});

$discovery = new OidcDiscovery(
httpClient: $httpClient,
requestFactory: $factory,
);

$metadata = $discovery->discover($issuer);

$this->assertSame($issuer, $metadata['issuer']);
$this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server/application/o/mcp', $requestedUrls[0]);
$this->assertSame('https://auth.example.com/.well-known/openid-configuration/application/o/mcp', $requestedUrls[1]);
$this->assertSame('https://auth.example.com/application/o/mcp/.well-known/openid-configuration', $requestedUrls[2]);
}

#[TestDox('issuer without path but with trailing slash is discovered and matched verbatim')]
public function testIssuerWithoutPathWithTrailingSlash(): void
{
$this->skipIfPsrHttpClientIsMissing();

$factory = new Psr17Factory();
$requestedUrls = [];
$issuer = 'https://auth.example.com/';
$validMetadata = [
'issuer' => $issuer,
'authorization_endpoint' => 'https://auth.example.com/authorize',
'token_endpoint' => 'https://auth.example.com/oauth/token',
'jwks_uri' => 'https://auth.example.com/.well-known/jwks.json',
'code_challenge_methods_supported' => ['S256'],
];

$httpClient = $this->createMock(ClientInterface::class);
$httpClient->expects($this->once())
->method('sendRequest')
->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface {
$requestedUrls[] = (string) $request->getUri();

return $factory->createResponse(200)->withBody(
$factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)),
);
});

$discovery = new OidcDiscovery(
httpClient: $httpClient,
requestFactory: $factory,
);

$metadata = $discovery->discover($issuer);

$this->assertSame($issuer, $metadata['issuer']);
$this->assertSame(['https://auth.example.com/.well-known/oauth-authorization-server'], $requestedUrls);
}

private function skipIfPsrHttpClientIsMissing(): void
{
if (!interface_exists(ClientInterface::class)) {
Expand Down