[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
11 changed files with 320 additions and 16 deletions

View file

@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
0.8.0 0.8.0
----- -----
* Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy.
* Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing. * Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing.
* Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. * Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it.
* Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. * Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`.

View file

@ -1,7 +1,7 @@
parameters: parameters:
ignoreErrors: ignoreErrors:
- -
message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\<Mcp\\Schema\\Content\\BlobResourceContents\|Mcp\\Schema\\Content\\TextResourceContents\>\} but returns array\{contents\: array\<Mcp\\Schema\\Content\\ResourceContents\>\}\.$#' message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\<Mcp\\Schema\\Content\\BlobResourceContents\|Mcp\\Schema\\Content\\TextResourceContents\>, ttlMs\?\: int, cacheScope\?\: string\} but returns array\{contents\: array\<Mcp\\Schema\\Content\\ResourceContents\>, ttlMs\?\: int\<0, max\>, cacheScope\?\: ''private''\|''public''\}\.$#'
identifier: return.type identifier: return.type
count: 1 count: 1
path: src/Schema/Result/ReadResourceResult.php path: src/Schema/Result/ReadResourceResult.php

View file

@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\BlobResourceContents;
use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\ResourceContents;
use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\Enum\CacheScope;
use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\JsonRpc\ResultInterface;
/** /**
@ -31,15 +32,27 @@ class ReadResourceResult implements ResultInterface
* Create a new ReadResourceResult. * 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 function __construct(
public readonly array $contents, 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{ * @param array{
* contents: array<TextResourceContentsData|BlobResourceContentsData>, * contents: array<TextResourceContentsData|BlobResourceContentsData>,
* ttlMs?: int,
* cacheScope?: string,
* } $data * } $data
*/ */
public static function fromArray(array $data): self 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{ * @return array{
* contents: array<BlobResourceContents|TextResourceContents>, * contents: array<BlobResourceContents|TextResourceContents>,
* ttlMs?: int,
* cacheScope?: string,
* } * }
*/ */
public function jsonSerialize(): array public function jsonSerialize(): array
{ {
return [ $data = ['contents' => $this->contents];
'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\RequestStateCodec;
use Mcp\Server\Stateless\StandardHeaderValidator; use Mcp\Server\Stateless\StandardHeaderValidator;
use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Stateless\StatelessProtocol;
use Mcp\Server\Wire\CachePolicy;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -107,6 +108,8 @@ final class Builder
private ?ProtocolVersion $protocolVersion = null; private ?ProtocolVersion $protocolVersion = null;
private ?CachePolicy $cachePolicy = null;
private ?string $requestStateKey = null; private ?string $requestStateKey = null;
private int $requestStateTtl = 600; private int $requestStateTtl = 600;
@ -255,6 +258,28 @@ final class Builder
return $this; 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 * Sets the key signing the `requestState` carried across the rounds of a
* multi round-trip request (SEP-2322). Without one, every echoed state is * multi round-trip request (SEP-2322). Without one, every echoed state is
@ -761,6 +786,7 @@ final class Builder
requestStateCodec: null !== $this->requestStateKey requestStateCodec: null !== $this->requestStateKey
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
: null, : null,
cachePolicy: $this->cachePolicy,
); );
} }

View file

@ -73,8 +73,10 @@ final class ReadResourceHandler implements RequestHandlerInterface
$result = $this->referenceHandler->handle($reference, $arguments); $result = $this->referenceHandler->handle($reference, $arguments);
// An ask is a result in its own right, not resource contents. // An ask is a result in its own right, not resource contents;
if ($result instanceof InputRequiredResult) { // 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); return new Response($request->getId(), $result);
} }
@ -82,7 +84,7 @@ final class ReadResourceHandler implements RequestHandlerInterface
} else { } else {
$result = $this->referenceHandler->handle($reference, $arguments); $result = $this->referenceHandler->handle($reference, $arguments);
if ($result instanceof InputRequiredResult) { if ($result instanceof InputRequiredResult || $result instanceof ReadResourceResult) {
return new Response($request->getId(), $result); 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\Protocol;
use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\Session; use Mcp\Server\Session\Session;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Server\Wire\Rev2026Codec; use Mcp\Server\Wire\Rev2026Codec;
use Mcp\Server\Wire\WireCodecInterface; use Mcp\Server\Wire\WireCodecInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -98,8 +99,9 @@ final class StatelessProtocol
?WireCodecInterface $codec = null, ?WireCodecInterface $codec = null,
private readonly ?StandardHeaderValidator $headerValidator = null, private readonly ?StandardHeaderValidator $headerValidator = null,
private readonly ?RequestStateCodec $requestStateCodec = 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) { if (null === $this->headerValidator) {
// Not fatal: a transport without a header layer — stdio — has // 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', 'resources/read',
]; ];
private readonly CachePolicy $cachePolicy;
public function __construct( public function __construct(
private readonly ?Implementation $serverInfo = null, private readonly ?Implementation $serverInfo = null,
private readonly CacheScope $defaultCacheScope = CacheScope::Private, ?CachePolicy $cachePolicy = null,
private readonly int $defaultTtlMs = 0,
) { ) {
$this->cachePolicy = $cachePolicy ?? CachePolicy::none();
} }
public function encodeResult(string $method, array $result, bool $cacheable = true): array 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, * Fills `ttlMs`/`cacheScope`, most-specific author first: a value the
* then configured policy, then "private, do not cache". * result itself carries, then the configured {@see CachePolicy}, whose own
* default is "private, do not cache".
* *
* @param array<string, mixed> $result * @param array<string, mixed> $result
* *
@ -106,11 +109,11 @@ final class Rev2026Codec implements WireCodecInterface
// Invalid authored values fall through to the next author down. // Invalid authored values fall through to the next author down.
if (!\is_int($ttl) || $ttl < 0) { if (!\is_int($ttl) || $ttl < 0) {
$ttl = $this->defaultTtlMs; $ttl = $this->cachePolicy->ttlFor($method);
} }
if (!\is_string($scope) || null === CacheScope::tryFrom($scope)) { if (!\is_string($scope) || null === CacheScope::tryFrom($scope)) {
$scope = $this->defaultCacheScope->value; $scope = $this->cachePolicy->scopeFor($method)->value;
} }
return [...$result, 'ttlMs' => $ttl, 'cacheScope' => $scope]; return [...$result, 'ttlMs' => $ttl, 'cacheScope' => $scope];

View file

@ -30,12 +30,14 @@ use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Enum\CacheScope;
use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server; use Mcp\Server;
use Mcp\Server\Transport\StatelessHttpTransport; use Mcp\Server\Transport\StatelessHttpTransport;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\Elements;
use Mcp\Tests\Conformance\FileLogger; use Mcp\Tests\Conformance\FileLogger;
use Mcp\Tests\Conformance\MrtrElements; use Mcp\Tests\Conformance\MrtrElements;
@ -125,6 +127,15 @@ $protocol = Server::builder()
->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first')
// Fixed so a retry landing on another process still verifies. // Fixed so a retry landing on another process still verifies.
->setRequestState(str_repeat('conformance-fixture-key-', 2)) ->setRequestState(str_repeat('conformance-fixture-key-', 2))
// Lists are the same for everyone here; a read is not.
->setCachePolicy(
CachePolicy::default(60_000)
->withMethod('tools/list', 3_600_000, CacheScope::Public)
->withMethod('prompts/list', 3_600_000, CacheScope::Public)
->withMethod('resources/list', 3_600_000, CacheScope::Public)
->withMethod('resources/templates/list', 3_600_000, CacheScope::Public)
->withMethod('server/discover', 3_600_000, CacheScope::Public),
)
->buildStateless([ProtocolVersion::V2026_07_28]); ->buildStateless([ProtocolVersion::V2026_07_28]);
$transport = new StatelessHttpTransport($protocol, logger: $logger); $transport = new StatelessHttpTransport($protocol, logger: $logger);

View file

@ -13,18 +13,22 @@ namespace Mcp\Tests\Unit\Server\Stateless;
use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Schema\ClientCapabilities; use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Enum\CacheScope;
use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Schema\Result\ReadResourceResult;
use Mcp\Server; use Mcp\Server;
use Mcp\Server\RequestContext; use Mcp\Server\RequestContext;
use Mcp\Server\Stateless\RequestMeta; use Mcp\Server\Stateless\RequestMeta;
use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Stateless\StatelessProtocol;
use Mcp\Server\Stateless\StatelessResult; use Mcp\Server\Stateless\StatelessResult;
use Mcp\Server\Wire\CachePolicy;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -543,6 +547,34 @@ class StatelessProtocolTest extends TestCase
$this->assertArrayHasKey('url', $last['error']['data']['requiredCapabilities']['elicitation']); $this->assertArrayHasKey('url', $last['error']['data']['requiredCapabilities']['elicitation']);
} }
#[TestDox('a resource can set its own freshness, overriding the policy')]
public function testResourceAuthoredCacheHintsWin(): void
{
$protocol = Server::builder()
->setServerInfo('test-server', '1.0.0')
->setCachePolicy(CachePolicy::default(60_000, CacheScope::Public))
->addResource(
static fn (): ReadResourceResult => new ReadResourceResult(
[new TextResourceContents('test://volatile', 'text/plain', 'now')],
ttlMs: 250,
cacheScope: CacheScope::Private,
),
'test://volatile',
'volatile',
'A resource that decides its own freshness',
)
->addResource(static fn (): string => 'body', 'test://plain', 'plain', 'A resource that defers to policy')
->buildStateless([ProtocolVersion::V2026_07_28]);
$volatile = self::call($protocol, 'resources/read', ['uri' => 'test://volatile'], ['Mcp-Name' => 'test://volatile']);
$this->assertSame(250, $volatile['body']['result']['ttlMs']);
$this->assertSame('private', $volatile['body']['result']['cacheScope']);
$plain = self::call($protocol, 'resources/read', ['uri' => 'test://plain'], ['Mcp-Name' => 'test://plain']);
$this->assertSame(60_000, $plain['body']['result']['ttlMs']);
$this->assertSame('public', $plain['body']['result']['cacheScope']);
}
#[TestDox('a first-round read carries caching hints')] #[TestDox('a first-round read carries caching hints')]
public function testFirstRoundReadIsCacheable(): void public function testFirstRoundReadIsCacheable(): void
{ {

View file

@ -0,0 +1,113 @@
<?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\Wire;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\CacheScope;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Server\Wire\Rev2026Codec;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class CachePolicyTest extends TestCase
{
#[TestDox('the default policy says nothing is fresh and nothing is shared')]
public function testNoneIsConservative(): void
{
$policy = CachePolicy::none();
$this->assertSame(0, $policy->ttlFor('tools/list'));
$this->assertSame(CacheScope::Private, $policy->scopeFor('tools/list'));
}
#[TestDox('a default applies to every method')]
public function testDefaultApplies(): void
{
$policy = CachePolicy::default(60_000, CacheScope::Public);
$this->assertSame(60_000, $policy->ttlFor('tools/list'));
$this->assertSame(60_000, $policy->ttlFor('resources/read'));
$this->assertSame(CacheScope::Public, $policy->scopeFor('resources/read'));
}
#[TestDox('a per-method override wins, and leaves the others alone')]
public function testPerMethodOverride(): void
{
$policy = CachePolicy::default(60_000)
->withMethod('tools/list', 3_600_000, CacheScope::Public);
$this->assertSame(3_600_000, $policy->ttlFor('tools/list'));
$this->assertSame(CacheScope::Public, $policy->scopeFor('tools/list'));
$this->assertSame(60_000, $policy->ttlFor('resources/read'));
$this->assertSame(CacheScope::Private, $policy->scopeFor('resources/read'));
}
#[TestDox('the policy is immutable')]
public function testWithMethodDoesNotMutate(): void
{
$base = CachePolicy::default(1_000);
$narrowed = $base->withMethod('tools/list', 5_000);
$this->assertSame(1_000, $base->ttlFor('tools/list'));
$this->assertSame(5_000, $narrowed->ttlFor('tools/list'));
}
#[TestDox('a negative TTL is refused: the spec requires zero or more')]
public function testNegativeTtlIsRefused(): void
{
$this->expectException(InvalidArgumentException::class);
CachePolicy::default(-1);
}
#[TestDox('a negative per-method TTL is refused too')]
public function testNegativePerMethodTtlIsRefused(): void
{
$this->expectException(InvalidArgumentException::class);
CachePolicy::default(0)->withMethod('tools/list', -5);
}
#[TestDox('the codec stamps the policy onto a cacheable result')]
public function testCodecAppliesThePolicy(): void
{
$codec = new Rev2026Codec(null, CachePolicy::default(60_000)->withMethod('tools/list', 3_600_000, CacheScope::Public));
$tools = $codec->encodeResult('tools/list', ['tools' => []]);
$this->assertSame(3_600_000, $tools['ttlMs']);
$this->assertSame('public', $tools['cacheScope']);
$read = $codec->encodeResult('resources/read', ['contents' => []]);
$this->assertSame(60_000, $read['ttlMs']);
$this->assertSame('private', $read['cacheScope']);
}
#[TestDox('a value the result carries wins over the policy')]
public function testAuthoredValueWins(): void
{
$codec = new Rev2026Codec(null, CachePolicy::default(60_000, CacheScope::Public));
$encoded = $codec->encodeResult('resources/read', ['contents' => [], 'ttlMs' => 500, 'cacheScope' => 'private']);
$this->assertSame(500, $encoded['ttlMs']);
$this->assertSame('private', $encoded['cacheScope']);
}
#[TestDox('a method the spec does not make cacheable is left alone')]
public function testNonCacheableMethodIsUntouched(): void
{
$encoded = (new Rev2026Codec(null, CachePolicy::default(60_000)))->encodeResult('tools/call', ['content' => []]);
$this->assertArrayNotHasKey('ttlMs', $encoded);
$this->assertArrayNotHasKey('cacheScope', $encoded);
}
}