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