[Server] Let a server say how long its answers stay fresh (#450)
Every cacheable result went out as ttlMs 0, private — the letter of SEP-2549 and none of its point, with no way to change it. CachePolicy adds a default and per-method overrides; a resource can override per read. The conservative default stands, because public means a shared cache may serve one caller's answer to another.
This commit is contained in:
parent
78a8926f17
commit
7dd72d82de
6 changed files with 320 additions and 16 deletions
|
|
@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException;
|
|||
use Mcp\Schema\Content\BlobResourceContents;
|
||||
use Mcp\Schema\Content\ResourceContents;
|
||||
use Mcp\Schema\Content\TextResourceContents;
|
||||
use Mcp\Schema\Enum\CacheScope;
|
||||
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||
|
||||
/**
|
||||
|
|
@ -30,16 +31,28 @@ class ReadResourceResult implements ResultInterface
|
|||
/**
|
||||
* Create a new ReadResourceResult.
|
||||
*
|
||||
* @param ResourceContents[] $contents The contents of the resource
|
||||
* @param ResourceContents[] $contents The contents of the resource
|
||||
* @param ?int $ttlMs How long a client may consider this fresh, in milliseconds. Null
|
||||
* leaves it to the server's configured {@see \Mcp\Server\Wire\CachePolicy};
|
||||
* set it when one resource's freshness differs from the rest.
|
||||
* @param ?CacheScope $cacheScope Who may cache it. Null defers to the policy. `Private` for anything
|
||||
* shaped by who asked.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $contents,
|
||||
public readonly ?int $ttlMs = null,
|
||||
public readonly ?CacheScope $cacheScope = null,
|
||||
) {
|
||||
if (null !== $this->ttlMs && $this->ttlMs < 0) {
|
||||
throw new InvalidArgumentException(\sprintf('A resource "ttlMs" must be zero or more, got %d.', $this->ttlMs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* contents: array<TextResourceContentsData|BlobResourceContentsData>,
|
||||
* ttlMs?: int,
|
||||
* cacheScope?: string,
|
||||
* } $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
|
|
@ -59,18 +72,32 @@ class ReadResourceResult implements ResultInterface
|
|||
}
|
||||
}
|
||||
|
||||
return new self($contents);
|
||||
$scope = isset($data['cacheScope']) && \is_string($data['cacheScope']) ? CacheScope::tryFrom($data['cacheScope']) : null;
|
||||
|
||||
return new self($contents, isset($data['ttlMs']) && \is_int($data['ttlMs']) ? $data['ttlMs'] : null, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* contents: array<BlobResourceContents|TextResourceContents>,
|
||||
* ttlMs?: int,
|
||||
* cacheScope?: string,
|
||||
* }
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'contents' => $this->contents,
|
||||
];
|
||||
$data = ['contents' => $this->contents];
|
||||
|
||||
// Only what this result actually decided; the wire codec fills the rest
|
||||
// from policy, and an absent member is the signal for it to do so.
|
||||
if (null !== $this->ttlMs) {
|
||||
$data['ttlMs'] = $this->ttlMs;
|
||||
}
|
||||
|
||||
if (null !== $this->cacheScope) {
|
||||
$data['cacheScope'] = $this->cacheScope->value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ use Mcp\Server\Session\SessionStoreInterface;
|
|||
use Mcp\Server\Stateless\RequestStateCodec;
|
||||
use Mcp\Server\Stateless\StandardHeaderValidator;
|
||||
use Mcp\Server\Stateless\StatelessProtocol;
|
||||
use Mcp\Server\Wire\CachePolicy;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
|
@ -107,6 +108,8 @@ final class Builder
|
|||
|
||||
private ?ProtocolVersion $protocolVersion = null;
|
||||
|
||||
private ?CachePolicy $cachePolicy = null;
|
||||
|
||||
private ?string $requestStateKey = null;
|
||||
|
||||
private int $requestStateTtl = 600;
|
||||
|
|
@ -255,6 +258,28 @@ final class Builder
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets how long, and to whom, this server's answers may be cached (SEP-2549).
|
||||
*
|
||||
* The modern lifecycle must put `ttlMs` and `cacheScope` on every cacheable
|
||||
* result; without a policy it says "private, immediately stale", which is
|
||||
* conformant and forfeits the point. Build one with
|
||||
* {@see CachePolicy::default()} and narrow it per method:
|
||||
*
|
||||
* ```php
|
||||
* $builder->setCachePolicy(
|
||||
* CachePolicy::default(60_000)
|
||||
* ->withMethod('tools/list', 3_600_000, CacheScope::Public),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
public function setCachePolicy(CachePolicy $policy): self
|
||||
{
|
||||
$this->cachePolicy = $policy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the key signing the `requestState` carried across the rounds of a
|
||||
* multi round-trip request (SEP-2322). Without one, every echoed state is
|
||||
|
|
@ -761,6 +786,7 @@ final class Builder
|
|||
requestStateCodec: null !== $this->requestStateKey
|
||||
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
|
||||
: null,
|
||||
cachePolicy: $this->cachePolicy,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,8 +73,10 @@ final class ReadResourceHandler implements RequestHandlerInterface
|
|||
|
||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||
|
||||
// An ask is a result in its own right, not resource contents.
|
||||
if ($result instanceof InputRequiredResult) {
|
||||
// An ask is a result in its own right, not resource contents;
|
||||
// and a handler that built the whole result keeps what it
|
||||
// decided, caching hints included.
|
||||
if ($result instanceof InputRequiredResult || $result instanceof ReadResourceResult) {
|
||||
return new Response($request->getId(), $result);
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +84,7 @@ final class ReadResourceHandler implements RequestHandlerInterface
|
|||
} else {
|
||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||
|
||||
if ($result instanceof InputRequiredResult) {
|
||||
if ($result instanceof InputRequiredResult || $result instanceof ReadResourceResult) {
|
||||
return new Response($request->getId(), $result);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
|||
use Mcp\Server\Protocol;
|
||||
use Mcp\Server\Session\InMemorySessionStore;
|
||||
use Mcp\Server\Session\Session;
|
||||
use Mcp\Server\Wire\CachePolicy;
|
||||
use Mcp\Server\Wire\Rev2026Codec;
|
||||
use Mcp\Server\Wire\WireCodecInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
|
@ -98,8 +99,9 @@ final class StatelessProtocol
|
|||
?WireCodecInterface $codec = null,
|
||||
private readonly ?StandardHeaderValidator $headerValidator = null,
|
||||
private readonly ?RequestStateCodec $requestStateCodec = null,
|
||||
?CachePolicy $cachePolicy = null,
|
||||
) {
|
||||
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo);
|
||||
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy);
|
||||
|
||||
if (null === $this->headerValidator) {
|
||||
// Not fatal: a transport without a header layer — stdio — has
|
||||
|
|
|
|||
87
src/Server/Wire/CachePolicy.php
Normal file
87
src/Server/Wire/CachePolicy.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?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\Wire;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Schema\Enum\CacheScope;
|
||||
|
||||
/**
|
||||
* How long, and to whom, a server's answers may be cached (SEP-2549).
|
||||
*
|
||||
* The default says "do not cache": `ttlMs: 0` and `private`. That is the honest
|
||||
* answer for a server nobody has told us about — `public` lets a shared proxy
|
||||
* serve one caller's answer to another, which is a disclosure decision only the
|
||||
* operator can make.
|
||||
*
|
||||
* Granularity is per method, because that is what the caching hint describes: a
|
||||
* `tools/list` is usually identical for everyone and worth a long public TTL,
|
||||
* while a `resources/read` usually is not.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class CachePolicy
|
||||
{
|
||||
/**
|
||||
* @param array<string, array{int, CacheScope}> $perMethod
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly int $ttlMs,
|
||||
private readonly CacheScope $scope,
|
||||
private readonly array $perMethod = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The conservative default: nothing is fresh, nothing is shared.
|
||||
*/
|
||||
public static function none(): self
|
||||
{
|
||||
return new self(0, CacheScope::Private);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $ttlMs how long an answer stays fresh, in milliseconds
|
||||
*/
|
||||
public static function default(int $ttlMs, CacheScope $scope = CacheScope::Private): self
|
||||
{
|
||||
if ($ttlMs < 0) {
|
||||
throw new InvalidArgumentException(\sprintf('A cache TTL must be zero or more milliseconds, got %d.', $ttlMs));
|
||||
}
|
||||
|
||||
return new self($ttlMs, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the policy for one method.
|
||||
*
|
||||
* Only the methods the specification makes cacheable are worth naming; any
|
||||
* other is accepted and simply never consulted.
|
||||
*/
|
||||
public function withMethod(string $method, int $ttlMs, CacheScope $scope = CacheScope::Private): self
|
||||
{
|
||||
if ($ttlMs < 0) {
|
||||
throw new InvalidArgumentException(\sprintf('A cache TTL must be zero or more milliseconds, got %d.', $ttlMs));
|
||||
}
|
||||
|
||||
return new self($this->ttlMs, $this->scope, [...$this->perMethod, $method => [$ttlMs, $scope]]);
|
||||
}
|
||||
|
||||
public function ttlFor(string $method): int
|
||||
{
|
||||
return $this->perMethod[$method][0] ?? $this->ttlMs;
|
||||
}
|
||||
|
||||
public function scopeFor(string $method): CacheScope
|
||||
{
|
||||
return $this->perMethod[$method][1] ?? $this->scope;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,11 +44,13 @@ final class Rev2026Codec implements WireCodecInterface
|
|||
'resources/read',
|
||||
];
|
||||
|
||||
private readonly CachePolicy $cachePolicy;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?Implementation $serverInfo = null,
|
||||
private readonly CacheScope $defaultCacheScope = CacheScope::Private,
|
||||
private readonly int $defaultTtlMs = 0,
|
||||
?CachePolicy $cachePolicy = null,
|
||||
) {
|
||||
$this->cachePolicy = $cachePolicy ?? CachePolicy::none();
|
||||
}
|
||||
|
||||
public function encodeResult(string $method, array $result, bool $cacheable = true): array
|
||||
|
|
@ -84,8 +86,9 @@ final class Rev2026Codec implements WireCodecInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* Fills `ttlMs`/`cacheScope`, most-specific author first: an authored value,
|
||||
* then configured policy, then "private, do not cache".
|
||||
* Fills `ttlMs`/`cacheScope`, most-specific author first: a value the
|
||||
* result itself carries, then the configured {@see CachePolicy}, whose own
|
||||
* default is "private, do not cache".
|
||||
*
|
||||
* @param array<string, mixed> $result
|
||||
*
|
||||
|
|
@ -106,11 +109,11 @@ final class Rev2026Codec implements WireCodecInterface
|
|||
|
||||
// Invalid authored values fall through to the next author down.
|
||||
if (!\is_int($ttl) || $ttl < 0) {
|
||||
$ttl = $this->defaultTtlMs;
|
||||
$ttl = $this->cachePolicy->ttlFor($method);
|
||||
}
|
||||
|
||||
if (!\is_string($scope) || null === CacheScope::tryFrom($scope)) {
|
||||
$scope = $this->defaultCacheScope->value;
|
||||
$scope = $this->cachePolicy->scopeFor($method)->value;
|
||||
}
|
||||
|
||||
return [...$result, 'ttlMs' => $ttl, 'cacheScope' => $scope];
|
||||
|
|
|
|||
Loading…
Reference in a new issue