From 22abd8ed230b21445820d14e689300b2a8b53961 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 6 Jul 2026 01:15:23 +0200 Subject: [PATCH 1/4] [Server] Expose OAuth protected resource metadata as a reusable request handler --- composer.json | 1 + docs/authorization.md | 58 ++++++++++++ docs/transports.md | 5 + .../oauth-resource-metadata/McpElements.php | 30 ++++++ .../server/oauth-resource-metadata/README.md | 50 ++++++++++ .../server/oauth-resource-metadata/server.php | 65 +++++++++++++ .../ProtectedResourceMetadataMiddleware.php | 18 ++-- .../ProtectedResourceMetadataHandler.php | 60 ++++++++++++ .../ProtectedResourceMetadataHandlerTest.php | 93 +++++++++++++++++++ 9 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 examples/server/oauth-resource-metadata/McpElements.php create mode 100644 examples/server/oauth-resource-metadata/README.md create mode 100644 examples/server/oauth-resource-metadata/server.php create mode 100644 src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php diff --git a/composer.json b/composer.json index b883603b..f2cc25ad 100644 --- a/composer.json +++ b/composer.json @@ -79,6 +79,7 @@ "Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/", "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", + "Mcp\\Example\\Server\\OAuthResourceMetadata\\": "examples/server/oauth-resource-metadata/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" }, diff --git a/docs/authorization.md b/docs/authorization.md index 184eb7e0..2bc72f10 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -299,6 +299,64 @@ WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/ scope="mcp:read mcp:write" ``` +### Serving the metadata endpoint as a controller + +`ProtectedResourceMetadataMiddleware` is a thin path-guard adapter over +`ProtectedResourceMetadataHandler`, a plain PSR-15 +`RequestHandlerInterface` (`handle(ServerRequestInterface): ResponseInterface`). That +signature is the same shape as a Symfony/Laravel callable controller, so if your framework +already owns routing you can mount the handler directly instead of routing the well-known +`GET` through the MCP transport. The handler decides *what* to return; your router decides +*when* to call it. + +```php +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; + +$handler = new ProtectedResourceMetadataHandler(new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'https://mcp.example.com/mcp', +)); +``` + +**Symfony** — convert the request to PSR-7 and the response back with +[`symfony/psr-http-message-bridge`](https://symfony.com/doc/current/components/psr7.html): + +```php +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/.well-known/oauth-protected-resource', methods: ['GET'])] +public function metadata(Request $request): Response +{ + $psrRequest = (new PsrHttpFactory())->createRequest($request); + + return (new HttpFoundationFactory())->createResponse($this->handler->handle($psrRequest)); +} +``` + +**Laravel** — type-hint the PSR-7 request and return the PSR-7 response directly: + +```php +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +// Route::get('/.well-known/oauth-protected-resource', [MetadataController::class, 'metadata']); +public function metadata(ServerRequestInterface $request): ResponseInterface +{ + return $this->handler->handle($request); +} +``` + +The same pattern applies to the other endpoint-style OAuth middleware +(`ClientRegistrationMiddleware`, `OAuthProxyMiddleware`), which likewise self-select on a path +and short-circuit — they can be exposed as handlers the same way. See the framework-agnostic +[`oauth-resource-metadata`](../examples/server/oauth-resource-metadata/) example. + ## Custom Token Validators Implement `AuthorizationTokenValidatorInterface` for custom validation: diff --git a/docs/transports.md b/docs/transports.md index 049ca2d6..e57ae0bf 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -372,6 +372,11 @@ $response = $server->run($transport); ### Framework Integration +> **OAuth endpoints as controllers:** The examples below convert the framework request to +> PSR-7 and hand it to the MCP transport. The SDK's endpoint-style OAuth pieces (e.g. the RFC +> 9728 metadata endpoint) can be mounted the same way as plain callable controllers, without +> the transport — see [Serving the metadata endpoint as a controller](authorization.md#serving-the-metadata-endpoint-as-a-controller). + #### Symfony Integration First install the required PSR libraries: diff --git a/examples/server/oauth-resource-metadata/McpElements.php b/examples/server/oauth-resource-metadata/McpElements.php new file mode 100644 index 00000000..005972f4 --- /dev/null +++ b/examples/server/oauth-resource-metadata/McpElements.php @@ -0,0 +1,30 @@ +handle($request)`; `/mcp` keeps using the transport. +3. **As a framework controller** — convert the framework request to PSR-7 and the returned + PSR-7 response back. See [`docs/authorization.md`](../../../docs/authorization.md). + +## Run it + +```bash +php -S localhost:8000 examples/server/oauth-resource-metadata/server.php +``` + +Fetch the metadata (served by the handler, no transport, no middleware): + +```bash +curl -s http://localhost:8000/.well-known/oauth-protected-resource | jq +``` + +```json +{ + "authorization_servers": ["https://auth.example.com"], + "scopes_supported": ["mcp:read", "mcp:write"], + "resource": "http://localhost:8000/mcp", + "resource_name": "OAuth Resource Metadata Example", + "resource_documentation": "https://modelcontextprotocol.io" +} +``` + +The MCP endpoint still works over the transport as usual: + +```bash +curl -s -X POST http://localhost:8000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' +``` diff --git a/examples/server/oauth-resource-metadata/server.php b/examples/server/oauth-resource-metadata/server.php new file mode 100644 index 00000000..99af9006 --- /dev/null +++ b/examples/server/oauth-resource-metadata/server.php @@ -0,0 +1,65 @@ +createServerRequestFromGlobals(); + +$metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'http://localhost:8000/mcp', + resourceName: 'OAuth Resource Metadata Example', + resourceDocumentation: 'https://modelcontextprotocol.io', +); + +// The reusable handler — a plain PSR-15 RequestHandlerInterface, no transport, +// no middleware chain. Mount it on a framework route to serve the endpoint there. +$metadataHandler = new ProtectedResourceMetadataHandler($metadata); + +$path = $request->getUri()->getPath(); + +if ('GET' === $request->getMethod() && ProtectedResourceMetadata::DEFAULT_METADATA_PATH === $path) { + // The "callable controller" path: hand the request straight to the handler. + (new SapiEmitter())->emit($metadataHandler->handle($request)); + exit(0); +} + +// Everything else is normal MCP traffic over the streamable HTTP transport. +$server = Server::builder() + ->setServerInfo('OAuth Resource Metadata Example', '1.0.0') + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->build(); + +$transport = new StreamableHttpTransport($request, logger: logger()); + +(new SapiEmitter())->emit($server->run($transport)); diff --git a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php index 315f585f..67a0cdcc 100644 --- a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php @@ -11,8 +11,8 @@ namespace Mcp\Server\Transport\Http\Middleware; -use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -23,22 +23,25 @@ /** * Serves OAuth 2.0 Protected Resource Metadata (RFC 9728) at well-known endpoints. * + * This is a thin path-guard adapter: it decides *when* the metadata endpoint applies + * (a GET to one of the configured well-known paths) and delegates the *what* to + * {@see ProtectedResourceMetadataHandler}, the reusable request handler that can also be + * mounted directly as a framework controller. + * * @see https://datatracker.ietf.org/doc/html/rfc9728 * * @author Volodymyr Panivko */ final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface { - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; + private ProtectedResourceMetadataHandler $metadataHandler; public function __construct( private readonly ProtectedResourceMetadata $metadata, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ) { - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->metadataHandler = new ProtectedResourceMetadataHandler($metadata, $responseFactory, $streamFactory); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -47,10 +50,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($request); } - return $this->responseFactory - ->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + return $this->metadataHandler->handle($request); } private function isMetadataRequest(ServerRequestInterface $request): bool diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php new file mode 100644 index 00000000..0b0cf7f2 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php @@ -0,0 +1,60 @@ + + */ +final class ProtectedResourceMetadataHandler implements RequestHandlerInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + public function __construct( + private readonly ProtectedResourceMetadata $metadata, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php new file mode 100644 index 00000000..b751df4c --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php @@ -0,0 +1,93 @@ + + */ +class ProtectedResourceMetadataHandlerTest extends TestCase +{ + #[TestDox('handle returns protected resource metadata JSON')] + public function testHandleReturnsMetadataJson(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'https://mcp.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://mcp.example.com/docs', + localizedHumanReadable: [ + 'resource_name#uk' => 'Pryklad MCP API', + ], + ); + + $handler = new ProtectedResourceMetadataHandler( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + $this->assertSame('https://mcp.example.com/mcp', $payload['resource']); + $this->assertSame('Example MCP API', $payload['resource_name']); + $this->assertSame('https://mcp.example.com/docs', $payload['resource_documentation']); + $this->assertSame('Pryklad MCP API', $payload['resource_name#uk']); + } + + #[TestDox('handle serves metadata regardless of request path or method')] + public function testHandleIgnoresRoutingConcerns(): void + { + $factory = new Psr17Factory(); + + $handler = new ProtectedResourceMetadataHandler( + metadata: new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + ), + responseFactory: $factory, + streamFactory: $factory, + ); + + // The handler is the "controller action" — the caller (middleware or framework + // router) owns path/method matching, so an arbitrary request still yields metadata. + $request = $factory->createServerRequest('POST', 'https://mcp.example.com/anything'); + + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + } +} From 61cb2e482637d11a1a3b57490c75738435eecb72 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 6 Jul 2026 01:19:13 +0200 Subject: [PATCH 2/4] docs: show handler DI wiring in Symfony/Laravel controller examples --- docs/authorization.md | 72 ++++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/docs/authorization.md b/docs/authorization.md index 2bc72f10..54641511 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -309,46 +309,82 @@ already owns routing you can mount the handler directly instead of routing the w `GET` through the MCP transport. The handler decides *what* to return; your router decides *when* to call it. -```php -use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; -use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; - -$handler = new ProtectedResourceMetadataHandler(new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['mcp:read', 'mcp:write'], - resource: 'https://mcp.example.com/mcp', -)); -``` +Register the handler once in your container and inject it into the controller. -**Symfony** — convert the request to PSR-7 and the response back with +**Symfony** — register the handler as a service (`config/services.yaml`), then constructor-inject +it and convert to/from PSR-7 with [`symfony/psr-http-message-bridge`](https://symfony.com/doc/current/components/psr7.html): +```yaml +# config/services.yaml +services: + Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata: + arguments: + $authorizationServers: ['https://auth.example.com'] + $scopesSupported: ['mcp:read', 'mcp:write'] + $resource: 'https://mcp.example.com/mcp' + + # Autowired from the ProtectedResourceMetadata service above. + Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler: ~ +``` + ```php +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/.well-known/oauth-protected-resource', methods: ['GET'])] -public function metadata(Request $request): Response +final class MetadataController { - $psrRequest = (new PsrHttpFactory())->createRequest($request); + public function __construct( + private readonly ProtectedResourceMetadataHandler $handler, + ) {} + + #[Route('/.well-known/oauth-protected-resource', methods: ['GET'])] + public function metadata(Request $request): Response + { + $psrRequest = (new PsrHttpFactory())->createRequest($request); - return (new HttpFoundationFactory())->createResponse($this->handler->handle($psrRequest)); + return (new HttpFoundationFactory())->createResponse($this->handler->handle($psrRequest)); + } } ``` -**Laravel** — type-hint the PSR-7 request and return the PSR-7 response directly: +**Laravel** — bind the handler in a service provider, then constructor-inject it and return the +PSR-7 response directly (Laravel type-hints resolve PSR-7 requests via the same bridge): + +```php +// In a service provider's register(): +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; + +$this->app->singleton(ProtectedResourceMetadataHandler::class, fn () => new ProtectedResourceMetadataHandler( + new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'https://mcp.example.com/mcp', + ), +)); +``` ```php +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; // Route::get('/.well-known/oauth-protected-resource', [MetadataController::class, 'metadata']); -public function metadata(ServerRequestInterface $request): ResponseInterface +final class MetadataController { - return $this->handler->handle($request); + public function __construct( + private readonly ProtectedResourceMetadataHandler $handler, + ) {} + + public function metadata(ServerRequestInterface $request): ResponseInterface + { + return $this->handler->handle($request); + } } ``` From d5b1decc0edbfabe56d07540d0827fab4279b5fe Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 6 Jul 2026 01:21:57 +0200 Subject: [PATCH 3/4] Focus PR on transport refactor: drop docs and example changes --- composer.json | 1 - docs/authorization.md | 94 ------------------- docs/transports.md | 5 - .../oauth-resource-metadata/McpElements.php | 30 ------ .../server/oauth-resource-metadata/README.md | 50 ---------- .../server/oauth-resource-metadata/server.php | 65 ------------- 6 files changed, 245 deletions(-) delete mode 100644 examples/server/oauth-resource-metadata/McpElements.php delete mode 100644 examples/server/oauth-resource-metadata/README.md delete mode 100644 examples/server/oauth-resource-metadata/server.php diff --git a/composer.json b/composer.json index f2cc25ad..b883603b 100644 --- a/composer.json +++ b/composer.json @@ -79,7 +79,6 @@ "Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/", "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", - "Mcp\\Example\\Server\\OAuthResourceMetadata\\": "examples/server/oauth-resource-metadata/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" }, diff --git a/docs/authorization.md b/docs/authorization.md index 54641511..184eb7e0 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -299,100 +299,6 @@ WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/ scope="mcp:read mcp:write" ``` -### Serving the metadata endpoint as a controller - -`ProtectedResourceMetadataMiddleware` is a thin path-guard adapter over -`ProtectedResourceMetadataHandler`, a plain PSR-15 -`RequestHandlerInterface` (`handle(ServerRequestInterface): ResponseInterface`). That -signature is the same shape as a Symfony/Laravel callable controller, so if your framework -already owns routing you can mount the handler directly instead of routing the well-known -`GET` through the MCP transport. The handler decides *what* to return; your router decides -*when* to call it. - -Register the handler once in your container and inject it into the controller. - -**Symfony** — register the handler as a service (`config/services.yaml`), then constructor-inject -it and convert to/from PSR-7 with -[`symfony/psr-http-message-bridge`](https://symfony.com/doc/current/components/psr7.html): - -```yaml -# config/services.yaml -services: - Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata: - arguments: - $authorizationServers: ['https://auth.example.com'] - $scopesSupported: ['mcp:read', 'mcp:write'] - $resource: 'https://mcp.example.com/mcp' - - # Autowired from the ProtectedResourceMetadata service above. - Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler: ~ -``` - -```php -use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; - -final class MetadataController -{ - public function __construct( - private readonly ProtectedResourceMetadataHandler $handler, - ) {} - - #[Route('/.well-known/oauth-protected-resource', methods: ['GET'])] - public function metadata(Request $request): Response - { - $psrRequest = (new PsrHttpFactory())->createRequest($request); - - return (new HttpFoundationFactory())->createResponse($this->handler->handle($psrRequest)); - } -} -``` - -**Laravel** — bind the handler in a service provider, then constructor-inject it and return the -PSR-7 response directly (Laravel type-hints resolve PSR-7 requests via the same bridge): - -```php -// In a service provider's register(): -use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; -use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; - -$this->app->singleton(ProtectedResourceMetadataHandler::class, fn () => new ProtectedResourceMetadataHandler( - new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['mcp:read', 'mcp:write'], - resource: 'https://mcp.example.com/mcp', - ), -)); -``` - -```php -use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; - -// Route::get('/.well-known/oauth-protected-resource', [MetadataController::class, 'metadata']); -final class MetadataController -{ - public function __construct( - private readonly ProtectedResourceMetadataHandler $handler, - ) {} - - public function metadata(ServerRequestInterface $request): ResponseInterface - { - return $this->handler->handle($request); - } -} -``` - -The same pattern applies to the other endpoint-style OAuth middleware -(`ClientRegistrationMiddleware`, `OAuthProxyMiddleware`), which likewise self-select on a path -and short-circuit — they can be exposed as handlers the same way. See the framework-agnostic -[`oauth-resource-metadata`](../examples/server/oauth-resource-metadata/) example. - ## Custom Token Validators Implement `AuthorizationTokenValidatorInterface` for custom validation: diff --git a/docs/transports.md b/docs/transports.md index e57ae0bf..049ca2d6 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -372,11 +372,6 @@ $response = $server->run($transport); ### Framework Integration -> **OAuth endpoints as controllers:** The examples below convert the framework request to -> PSR-7 and hand it to the MCP transport. The SDK's endpoint-style OAuth pieces (e.g. the RFC -> 9728 metadata endpoint) can be mounted the same way as plain callable controllers, without -> the transport — see [Serving the metadata endpoint as a controller](authorization.md#serving-the-metadata-endpoint-as-a-controller). - #### Symfony Integration First install the required PSR libraries: diff --git a/examples/server/oauth-resource-metadata/McpElements.php b/examples/server/oauth-resource-metadata/McpElements.php deleted file mode 100644 index 005972f4..00000000 --- a/examples/server/oauth-resource-metadata/McpElements.php +++ /dev/null @@ -1,30 +0,0 @@ -handle($request)`; `/mcp` keeps using the transport. -3. **As a framework controller** — convert the framework request to PSR-7 and the returned - PSR-7 response back. See [`docs/authorization.md`](../../../docs/authorization.md). - -## Run it - -```bash -php -S localhost:8000 examples/server/oauth-resource-metadata/server.php -``` - -Fetch the metadata (served by the handler, no transport, no middleware): - -```bash -curl -s http://localhost:8000/.well-known/oauth-protected-resource | jq -``` - -```json -{ - "authorization_servers": ["https://auth.example.com"], - "scopes_supported": ["mcp:read", "mcp:write"], - "resource": "http://localhost:8000/mcp", - "resource_name": "OAuth Resource Metadata Example", - "resource_documentation": "https://modelcontextprotocol.io" -} -``` - -The MCP endpoint still works over the transport as usual: - -```bash -curl -s -X POST http://localhost:8000/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' -``` diff --git a/examples/server/oauth-resource-metadata/server.php b/examples/server/oauth-resource-metadata/server.php deleted file mode 100644 index 99af9006..00000000 --- a/examples/server/oauth-resource-metadata/server.php +++ /dev/null @@ -1,65 +0,0 @@ -createServerRequestFromGlobals(); - -$metadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['mcp:read', 'mcp:write'], - resource: 'http://localhost:8000/mcp', - resourceName: 'OAuth Resource Metadata Example', - resourceDocumentation: 'https://modelcontextprotocol.io', -); - -// The reusable handler — a plain PSR-15 RequestHandlerInterface, no transport, -// no middleware chain. Mount it on a framework route to serve the endpoint there. -$metadataHandler = new ProtectedResourceMetadataHandler($metadata); - -$path = $request->getUri()->getPath(); - -if ('GET' === $request->getMethod() && ProtectedResourceMetadata::DEFAULT_METADATA_PATH === $path) { - // The "callable controller" path: hand the request straight to the handler. - (new SapiEmitter())->emit($metadataHandler->handle($request)); - exit(0); -} - -// Everything else is normal MCP traffic over the streamable HTTP transport. -$server = Server::builder() - ->setServerInfo('OAuth Resource Metadata Example', '1.0.0') - ->setLogger(logger()) - ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__) - ->build(); - -$transport = new StreamableHttpTransport($request, logger: logger()); - -(new SapiEmitter())->emit($server->run($transport)); From b8fb4dbcebb544e5fa7650151938c6e524f07386 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 9 Jul 2026 00:07:20 +0200 Subject: [PATCH 4/4] docs: add changelog entry for ProtectedResourceMetadataHandler --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecccb16..5412379a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`. * Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification. * Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. +* Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break). 0.5.0 -----