[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:
parent
1fed089d71
commit
0058d93086
4 changed files with 163 additions and 9 deletions
|
|
@ -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
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue