[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
11 changed files with 320 additions and 16 deletions
|
|
@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
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.
|
||||
* 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()`.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
parameters:
|
||||
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
|
||||
count: 1
|
||||
path: src/Schema/Result/ReadResourceResult.php
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
@ -31,15 +32,27 @@ class ReadResourceResult implements ResultInterface
|
|||
* Create a new ReadResourceResult.
|
||||
*
|
||||
* @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];
|
||||
|
|
|
|||
|
|
@ -30,12 +30,14 @@ use Mcp\Schema\Content\ImageContent;
|
|||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||
use Mcp\Schema\Enum\CacheScope;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Transport\StatelessHttpTransport;
|
||||
use Mcp\Server\Wire\CachePolicy;
|
||||
use Mcp\Tests\Conformance\Elements;
|
||||
use Mcp\Tests\Conformance\FileLogger;
|
||||
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')
|
||||
// Fixed so a retry landing on another process still verifies.
|
||||
->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]);
|
||||
|
||||
$transport = new StatelessHttpTransport($protocol, logger: $logger);
|
||||
|
|
|
|||
|
|
@ -13,18 +13,22 @@ namespace Mcp\Tests\Unit\Server\Stateless;
|
|||
|
||||
use Mcp\Exception\MissingRequiredClientCapabilityException;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Content\TextResourceContents;
|
||||
use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||
use Mcp\Schema\Enum\CacheScope;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Schema\Result\ReadResourceResult;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\RequestContext;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Mcp\Server\Stateless\StatelessProtocol;
|
||||
use Mcp\Server\Stateless\StatelessResult;
|
||||
use Mcp\Server\Wire\CachePolicy;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
|
@ -543,6 +547,34 @@ class StatelessProtocolTest extends TestCase
|
|||
$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')]
|
||||
public function testFirstRoundReadIsCacheable(): void
|
||||
{
|
||||
|
|
|
|||
113
tests/Unit/Server/Wire/CachePolicyTest.php
Normal file
113
tests/Unit/Server/Wire/CachePolicyTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue