[Server] Expose OAuth protected resource metadata as a reusable request handler (#391)

* [Server] Expose OAuth protected resource metadata as a reusable request handler

* docs: show handler DI wiring in Symfony/Laravel controller examples

* Focus PR on transport refactor: drop docs and example changes

* docs: add changelog entry for ProtectedResourceMetadataHandler
This commit is contained in:
Christopher Hertel 2026-07-15 00:24:04 +02:00 • committed by GitHub
commit 0058d93086
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 163 additions and 9 deletions

View file

@ -40,6 +40,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
-----

View file

@ -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 @@ use Psr\Http\Server\RequestHandlerInterface;
/**
* 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 <sveneld300@gmail.com>
*/
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 @@ final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface
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

View file

@ -0,0 +1,60 @@
<?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\Server\Transport\Http\OAuth;
use Http\Discovery\Psr17FactoryDiscovery;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Serves OAuth 2.0 Protected Resource Metadata (RFC 9728) as a standalone request handler.
*
* This is a plain PSR-15 {@see RequestHandlerInterface} — the "controller" that decides *what*
* to return, independent of *when*. It can be used three ways from a single instance:
*
* - inside the MCP transport, wrapped by {@see \Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware};
* - as a bare PSR-7 handler in a hand-rolled front controller;
* - as a framework callable controller (Symfony/Laravel), by converting the framework
* request to PSR-7 and the returned PSR-7 response back — see docs/authorization.md.
*
* It performs no path or method matching: routing is the caller's responsibility (the
* middleware's guard, or the framework router).
*
* @see https://datatracker.ietf.org/doc/html/rfc9728
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
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)));
}
}

View file

@ -0,0 +1,93 @@
<?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\Tests\Unit\Server\Transport\Http\OAuth;
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Tests the standalone RFC 9728 metadata request handler.
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
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']);
}
}