Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Fix tools, prompts and resources sending `content`, `messages` and `contents` as a JSON object instead of an array when the handler returned a non-list array, e.g. the result of `array_filter()`. Named keys in nested results of mixed arrays no longer overwrite each other.
* Fix `SecureUrl` rejecting plain http on hosts under the reserved `.localhost` TLD (RFC 6761) and on IPv4 loopback addresses other than `127.0.0.1`, so `ProtectedResourceMetadata`, `OidcDiscovery` and `JwtTokenValidator::fromIssuer()` failed for local development setups like `http://shop.localhost:8080`. Plain http is now accepted for `localhost`, any `*.localhost`, `127.0.0.0/8` and `::1`; every other host still requires https.
* Tool, prompt and resource results and `TextContent` encode arrays and objects as compact JSON instead of pretty-printed JSON.
* Fix the result of a retried `resources/read` leaving out the required `ttlMs` and `cacheScope`, which the TypeScript SDK's client rejects, or keeping the hints its handler set: it now always carries `ttlMs: 0` and `cacheScope: private`.
* Fix tool name validation not following the spec's rule (1 to 128 characters of `A-Z`, `a-z`, `0-9`, `_`, `-` and `.`): names of 65 to 128 characters no longer log a warning, while names with a `/` or a trailing newline now do.
* Add `Psr6SessionStore` and `Psr6NotificationBus`, which work like `Psr16SessionStore` and `Psr16NotificationBus` over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool as `$cache`.
* Fix a nullable tool parameter with a default (e.g. `?string $category = 'books'`) receiving the default when the client explicitly sends `null`: `ReferenceHandler` now passes the `null` through.
Expand Down
5 changes: 3 additions & 2 deletions docs/run/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ conservative default stands until you change it.

A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy.

Results produced by an [MRTR retry](../handlers/input-required.md) are never given hints: their inputs
are not part of any cache key.
Results produced by an [MRTR retry](../handlers/input-required.md) always carry `ttlMs: 0` and
`cacheScope: "private"`, whatever the policy or the result says: their inputs are not part of any cache
key, so they must not be cached.
8 changes: 6 additions & 2 deletions src/Server/Wire/Rev2026Codec.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public function encodeResult(string $method, array $result, bool $cacheable = tr
$stamped = $this->stampResultType($method, $result);

return $this->stampServerInfo(
$cacheable ? $this->fillCacheHints($method, $stamped) : $stamped,
$this->fillCacheHints($method, $stamped, $cacheable),
);
}

Expand Down Expand Up @@ -94,7 +94,7 @@ private function stampResultType(string $method, array $result): array
*
* @return array<string, mixed>
*/
private function fillCacheHints(string $method, array $result): array
private function fillCacheHints(string $method, array $result, bool $cacheable): array
{
if (ResultType::Complete->value !== ($result['resultType'] ?? null)) {
return $result;
Expand All @@ -104,6 +104,10 @@ private function fillCacheHints(string $method, array $result): array
return $result;
}

if (!$cacheable) {
return [...$result, 'ttlMs' => 0, 'cacheScope' => CacheScope::Private->value];
}

$ttl = $result['ttlMs'] ?? null;
$scope = $result['cacheScope'] ?? null;

Expand Down
5 changes: 2 additions & 3 deletions src/Server/Wire/WireCodecInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ interface WireCodecInterface
*
* @param string $method the request method the result answers
* @param array<string, mixed> $result the neutral result body
* @param bool $cacheable whether this answer may carry caching hints at all — false for one
* produced by a multi round-trip retry, whose inputs are not part of
* any cache key
* @param bool $cacheable whether this answer may be cached — false for one produced by a
* multi round-trip retry, whose inputs are not part of any cache key
*
* @return array<string, mixed>
*/
Expand Down
8 changes: 3 additions & 5 deletions tests/Unit/Server/Stateless/StatelessProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1166,7 +1166,7 @@ public function testFirstRoundReadIsCacheable(): void
$this->assertArrayHasKey('cacheScope', $answer['body']['result']);
}

#[TestDox('a result produced by a multi round-trip retry carries no caching hints')]
#[TestDox('a result produced by a multi round-trip retry carries hints that forbid caching it')]
public function testMrtrRetryResultIsNotCacheable(): void
{
$answer = self::call(
Expand All @@ -1181,10 +1181,8 @@ public function testMrtrRetryResultIsNotCacheable(): void
);

$this->assertSame('complete', $answer['body']['result']['resultType']);
// The inputs are not part of any cache key, so the answer must not be
// presented as reusable.
$this->assertArrayNotHasKey('ttlMs', $answer['body']['result']);
$this->assertArrayNotHasKey('cacheScope', $answer['body']['result']);
$this->assertSame(0, $answer['body']['result']['ttlMs']);
$this->assertSame('private', $answer['body']['result']['cacheScope']);
}

#[TestDox('a listen stream acknowledges, then carries what the client asked for')]
Expand Down
13 changes: 13 additions & 0 deletions tests/Unit/Server/Wire/CachePolicyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,17 @@ public function testNonCacheableMethodIsUntouched(): void
$this->assertArrayNotHasKey('ttlMs', $encoded);
$this->assertArrayNotHasKey('cacheScope', $encoded);
}

#[TestDox('the result of a retry forbids caching, whatever the policy or the result say')]
public function testRetryResultForbidsCachingWhateverThePolicyOrItsHandlerSay(): void
{
$codec = new Rev2026Codec(null, CachePolicy::default(60_000, CacheScope::Public));

foreach ([['contents' => []], ['contents' => [], 'ttlMs' => 500, 'cacheScope' => 'public']] as $result) {
$encoded = $codec->encodeResult('resources/read', $result, cacheable: false);

$this->assertSame(0, $encoded['ttlMs']);
$this->assertSame('private', $encoded['cacheScope']);
}
}
}
Loading