[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:
Christopher Hertel 2026-08-18 23:53:16 +02:00 • committed by GitHub
commit 7dd72d82de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 320 additions and 16 deletions

View file

@ -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;
}
}

View file

@ -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,
);
}

View file

@ -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);
}

View file

@ -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

View 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;
}
}

View file

@ -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];