Skip to content

Commit 7974a2f

Browse files
committed
feat: add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
PSR-15 middleware that handles POST /register by delegating to a ClientRegistrarInterface and enriches /.well-known/oauth-authorization-server responses with the registration_endpoint.
1 parent 443af5c commit 7974a2f

5 files changed

Lines changed: 448 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
99
* Add client component for building MCP clients
1010
* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators)
1111
* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition`
12+
* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` in OIDC discovery
13+
* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
1214

1315
0.4.0
1416
-----
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Exception;
13+
14+
final class ClientRegistrationException extends \RuntimeException implements ExceptionInterface
15+
{
16+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Transport\Http\Middleware;
13+
14+
use Http\Discovery\Psr17FactoryDiscovery;
15+
use Mcp\Exception\ClientRegistrationException;
16+
use Mcp\Server\Transport\Http\OAuth\ClientRegistrarInterface;
17+
use Psr\Http\Message\ResponseFactoryInterface;
18+
use Psr\Http\Message\ResponseInterface;
19+
use Psr\Http\Message\ServerRequestInterface;
20+
use Psr\Http\Message\StreamFactoryInterface;
21+
use Psr\Http\Server\MiddlewareInterface;
22+
use Psr\Http\Server\RequestHandlerInterface;
23+
24+
/**
25+
* OAuth 2.0 Dynamic Client Registration (RFC 7591) middleware.
26+
*
27+
* Handles POST /register requests by delegating to a ClientRegistrarInterface
28+
* and enriches /.well-known/oauth-authorization-server responses with the
29+
* registration_endpoint.
30+
*/
31+
final class ClientRegistrationMiddleware implements MiddlewareInterface
32+
{
33+
private const REGISTRATION_PATH = '/register';
34+
35+
private ResponseFactoryInterface $responseFactory;
36+
private StreamFactoryInterface $streamFactory;
37+
38+
public function __construct(
39+
private readonly ClientRegistrarInterface $registrar,
40+
private readonly string $localBaseUrl,
41+
?ResponseFactoryInterface $responseFactory = null,
42+
?StreamFactoryInterface $streamFactory = null,
43+
) {
44+
if ('' === trim($localBaseUrl)) {
45+
throw new \InvalidArgumentException('The $localBaseUrl must not be empty.');
46+
}
47+
48+
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
49+
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
50+
}
51+
52+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
53+
{
54+
$path = $request->getUri()->getPath();
55+
56+
if ('POST' === $request->getMethod() && self::REGISTRATION_PATH === $path) {
57+
return $this->handleRegistration($request);
58+
}
59+
60+
$response = $handler->handle($request);
61+
62+
if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) {
63+
return $this->enrichAuthServerMetadata($response);
64+
}
65+
66+
return $response;
67+
}
68+
69+
private function handleRegistration(ServerRequestInterface $request): ResponseInterface
70+
{
71+
$body = $request->getBody()->__toString();
72+
$data = json_decode($body, true);
73+
74+
if (!\is_array($data)) {
75+
return $this->jsonResponse(400, [
76+
'error' => 'invalid_client_metadata',
77+
'error_description' => 'Request body must be valid JSON.',
78+
]);
79+
}
80+
81+
try {
82+
$result = $this->registrar->register($data);
83+
} catch (ClientRegistrationException $e) {
84+
return $this->jsonResponse(400, [
85+
'error' => 'invalid_client_metadata',
86+
'error_description' => $e->getMessage(),
87+
]);
88+
}
89+
90+
return $this->jsonResponse(201, $result);
91+
}
92+
93+
private function enrichAuthServerMetadata(ResponseInterface $response): ResponseInterface
94+
{
95+
if (200 !== $response->getStatusCode()) {
96+
return $response;
97+
}
98+
99+
$stream = $response->getBody();
100+
101+
if ($stream->isSeekable()) {
102+
$stream->rewind();
103+
}
104+
105+
$metadata = json_decode($stream->__toString(), true);
106+
107+
if (!\is_array($metadata)) {
108+
return $response;
109+
}
110+
111+
$metadata['registration_endpoint'] = rtrim($this->localBaseUrl, '/').self::REGISTRATION_PATH;
112+
113+
return $this->jsonResponse(200, $metadata, [
114+
'Cache-Control' => $response->getHeaderLine('Cache-Control'),
115+
]);
116+
}
117+
118+
/**
119+
* @param array<string, mixed> $data
120+
* @param array<string, string> $extraHeaders
121+
*/
122+
private function jsonResponse(int $status, array $data, array $extraHeaders = []): ResponseInterface
123+
{
124+
$response = $this->responseFactory
125+
->createResponse($status)
126+
->withHeader('Content-Type', 'application/json')
127+
->withBody($this->streamFactory->createStream(
128+
json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
129+
));
130+
131+
foreach ($extraHeaders as $name => $value) {
132+
if ('' !== $value) {
133+
$response = $response->withHeader($name, $value);
134+
}
135+
}
136+
137+
return $response;
138+
}
139+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Transport\Http\OAuth;
13+
14+
use Mcp\Exception\ClientRegistrationException;
15+
16+
/**
17+
* Interface for OAuth 2.0 Dynamic Client Registration (RFC 7591).
18+
*/
19+
interface ClientRegistrarInterface
20+
{
21+
/**
22+
* @param array<string, mixed> $registrationRequest
23+
*
24+
* @return array<string, mixed>
25+
*
26+
* @throws ClientRegistrationException
27+
*/
28+
public function register(array $registrationRequest): array;
29+
}

0 commit comments

Comments
 (0)