Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results (#1623)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
dbb7a20176
commit
c021d0a4f8
2 changed files with 1193 additions and 147 deletions
|
|
@ -0,0 +1,93 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests verifying that SEP-2549 caching hints set by a server on cacheable results
|
||||
/// are observed by a connected client.
|
||||
/// </summary>
|
||||
public class CacheableResultClientServerTests(ITestOutputHelper testOutputHelper)
|
||||
: ClientServerTestBase(testOutputHelper)
|
||||
{
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder
|
||||
.WithListToolsHandler((_, _) => new ValueTask<ListToolsResult>(new ListToolsResult
|
||||
{
|
||||
Tools = [new Tool { Name = "echo" }],
|
||||
TimeToLive = TimeSpan.FromMinutes(5),
|
||||
CacheScope = CacheScope.Public,
|
||||
}))
|
||||
.WithListPromptsHandler((_, _) => new ValueTask<ListPromptsResult>(new ListPromptsResult
|
||||
{
|
||||
Prompts = [new Prompt { Name = "greet" }],
|
||||
}))
|
||||
.WithListResourcesHandler((_, _) => new ValueTask<ListResourcesResult>(new ListResourcesResult
|
||||
{
|
||||
Resources = [new Resource { Uri = "test://resource", Name = "resource" }],
|
||||
}))
|
||||
.WithReadResourceHandler((request, _) => new ValueTask<ReadResourceResult>(new ReadResourceResult
|
||||
{
|
||||
Contents = [new TextResourceContents { Uri = request.Params!.Uri!, Text = "hi" }],
|
||||
TimeToLive = TimeSpan.FromSeconds(30),
|
||||
CacheScope = CacheScope.Private,
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTools_PropagatesCachingHints_ToClient()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ListToolsAsync(
|
||||
new ListToolsRequestParams(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Public, result.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResource_PropagatesCachingHints_ToClient()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ReadResourceAsync(
|
||||
"test://resource",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, result.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPrompts_WhenHandlerOmitsHints_ServerInjectsConservativeDefaults()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ListPromptsAsync(
|
||||
new ListPromptsRequestParams(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// SEP-2549: the handler left the hints unset, so the server fills in conservative defaults
|
||||
// (immediately stale, not shareable) rather than omitting the now-required fields.
|
||||
Assert.Equal(TimeSpan.Zero, result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, result.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListResources_WhenHandlerOmitsHints_ServerInjectsConservativeDefaults()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ListResourcesAsync(
|
||||
new ListResourcesRequestParams(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(TimeSpan.Zero, result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, result.CacheScope);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the SEP-2549 caching hints (<c>ttlMs</c> and <c>cacheScope</c>) carried by
|
||||
/// <see cref="ICacheableResult"/> implementations: the results of <c>tools/list</c>,
|
||||
/// <c>prompts/list</c>, <c>resources/list</c>, <c>resources/templates/list</c>, and
|
||||
/// <c>resources/read</c>.
|
||||
/// </summary>
|
||||
public static class CacheableResultTests
|
||||
{
|
||||
public static IEnumerable<object[]> CacheableResultTypes()
|
||||
{
|
||||
yield return new object[] { typeof(ListToolsResult) };
|
||||
yield return new object[] { typeof(ListPromptsResult) };
|
||||
yield return new object[] { typeof(ListResourcesResult) };
|
||||
yield return new object[] { typeof(ListResourceTemplatesResult) };
|
||||
yield return new object[] { typeof(ReadResourceResult) };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_SerializesTtlMsAsIntegerMilliseconds(Type type)
|
||||
{
|
||||
var result = (ICacheableResult)Activator.CreateInstance(type)!;
|
||||
result.TimeToLive = TimeSpan.FromMilliseconds(300_000);
|
||||
result.CacheScope = CacheScope.Public;
|
||||
|
||||
string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
Assert.True(node.ContainsKey("ttlMs"));
|
||||
Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind());
|
||||
Assert.Equal(300_000, node["ttlMs"]!.GetValue<long>());
|
||||
Assert.Equal("public", node["cacheScope"]!.GetValue<string>());
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive);
|
||||
Assert.Equal(CacheScope.Public, deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_PrivateScope_RoundTrips(Type type)
|
||||
{
|
||||
var result = (ICacheableResult)Activator.CreateInstance(type)!;
|
||||
result.TimeToLive = TimeSpan.Zero;
|
||||
result.CacheScope = CacheScope.Private;
|
||||
|
||||
string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
// A TTL of zero is meaningful (immediately stale) and must still be emitted.
|
||||
Assert.True(node.ContainsKey("ttlMs"));
|
||||
Assert.Equal(0, node["ttlMs"]!.GetValue<long>());
|
||||
Assert.Equal("private", node["cacheScope"]!.GetValue<string>());
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_OmitsCachingHints_WhenUnset(Type type)
|
||||
{
|
||||
object result = Activator.CreateInstance(type)!;
|
||||
|
||||
string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
// Backward compatibility: servers that do not set the hints must not emit them.
|
||||
Assert.False(node.ContainsKey("ttlMs"));
|
||||
Assert.False(node.ContainsKey("cacheScope"));
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Null(deserialized.TimeToLive);
|
||||
Assert.Null(deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesMissingHints_AsNull(Type type)
|
||||
{
|
||||
// A response from a server that predates SEP-2549 contains neither field.
|
||||
string json = "{}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Null(deserialized.TimeToLive);
|
||||
Assert.Null(deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesNegativeTtl(Type type)
|
||||
{
|
||||
// Per SEP-2549, a negative ttlMs is preserved on the DTO; callers SHOULD treat it as zero.
|
||||
string json = "{\"ttlMs\":-5,\"cacheScope\":\"public\"}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(-5), deserialized.TimeToLive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesOversizedTtl_ClampsInsteadOfThrowing(Type type)
|
||||
{
|
||||
// A hostile or buggy server could return a ttlMs that is a valid JSON integer but exceeds the
|
||||
// range representable by TimeSpan. Deserialization must not throw (which would break reading the
|
||||
// entire list); the value is clamped to TimeSpan.MaxValue instead.
|
||||
string json = "{\"ttlMs\":9999999999999999,\"cacheScope\":\"public\"}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.MaxValue, deserialized.TimeToLive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesLargeNegativeTtl_ClampsToMinValue(Type type)
|
||||
{
|
||||
string json = "{\"ttlMs\":-9999999999999999}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.MinValue, deserialized.TimeToLive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesMaxRepresentableTtl_DoesNotThrow(Type type)
|
||||
{
|
||||
// The largest whole-millisecond count that fits in a TimeSpan must round-trip without clamping.
|
||||
long maxWholeMs = long.MaxValue / TimeSpan.TicksPerMillisecond;
|
||||
string json = $"{{\"ttlMs\":{maxWholeMs}}}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.FromTicks(maxWholeMs * TimeSpan.TicksPerMillisecond), deserialized.TimeToLive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesHugeFloatTtl_ClampsInsteadOfThrowing(Type type)
|
||||
{
|
||||
// A fractional/exponent ttlMs whose tick count overflows to +Infinity is clamped to MaxValue
|
||||
// rather than throwing. (1e400 is beyond double range, so GetDouble() itself returns +Infinity.)
|
||||
Assert.Equal(
|
||||
TimeSpan.MaxValue,
|
||||
DeserializeTtl(type, "{\"ttlMs\":1e400}"));
|
||||
|
||||
// 1e308 is finite but overflows once scaled into tick-space.
|
||||
Assert.Equal(
|
||||
TimeSpan.MaxValue,
|
||||
DeserializeTtl(type, "{\"ttlMs\":1e308}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesNegativeInfinityFloatTtl_ClampsToMinValue(Type type)
|
||||
{
|
||||
// A large negative exponent ttlMs yields -Infinity from GetDouble(); it must clamp to MinValue,
|
||||
// not silently become long.MinValue ticks via the cast.
|
||||
Assert.Equal(
|
||||
TimeSpan.MinValue,
|
||||
DeserializeTtl(type, "{\"ttlMs\":-1e400}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesUnknownCacheScope_AsNull(Type type)
|
||||
{
|
||||
// A future/unknown cacheScope string must not break deserialization of the entire result; it is
|
||||
// tolerated and surfaced as null (equivalent to an absent field, which clients treat as public).
|
||||
// Non-string tokens, including objects and arrays, must likewise be tolerated and fully consumed.
|
||||
foreach (string scope in new[] { "\"shared\"", "\"\"", "123", "true", "null", "{}", "[]", "{\"a\":1}", "[1,2]" })
|
||||
{
|
||||
string json = $"{{\"cacheScope\":{scope}}}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Null(deserialized.CacheScope);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesCacheScope_CaseInsensitively(Type type)
|
||||
{
|
||||
// Casing of the security-relevant "private" hint must be honored rather than silently dropped to
|
||||
// null (which clients treat as public), so matching is case-insensitive on read.
|
||||
foreach (string scope in new[] { "PUBLIC", "Public", "pUbLiC" })
|
||||
{
|
||||
var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(CacheScope.Public, result.CacheScope);
|
||||
}
|
||||
|
||||
foreach (string scope in new[] { "PRIVATE", "Private", "pRiVaTe" })
|
||||
{
|
||||
var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(CacheScope.Private, result.CacheScope);
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan? DeserializeTtl(Type type, string json) =>
|
||||
((ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!).TimeToLive;
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesFractionalTtl(Type type)
|
||||
{
|
||||
string json = "{\"ttlMs\":1.5}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.FromTicks((long)(1.5 * TimeSpan.TicksPerMillisecond)), deserialized.TimeToLive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void CacheScope_SerializesAsLowercaseStrings()
|
||||
{
|
||||
Assert.Equal("\"public\"", JsonSerializer.Serialize(CacheScope.Public, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Equal("\"private\"", JsonSerializer.Serialize(CacheScope.Private, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Equal(CacheScope.Public, JsonSerializer.Deserialize<CacheScope>("\"public\"", McpJsonUtilities.DefaultOptions));
|
||||
Assert.Equal(CacheScope.Private, JsonSerializer.Deserialize<CacheScope>("\"private\"", McpJsonUtilities.DefaultOptions));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesTtlWithoutCacheScope(Type type)
|
||||
{
|
||||
// ttlMs present, cacheScope absent: the SEP says an absent scope defaults to "public",
|
||||
// but the SDK only propagates the wire value, so the DTO reports null (caller applies default).
|
||||
string json = "{\"ttlMs\":1000}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), deserialized.TimeToLive);
|
||||
Assert.Null(deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_DeserializesCacheScopeWithoutTtl(Type type)
|
||||
{
|
||||
// cacheScope present, ttlMs absent: a server may classify cacheability without a freshness hint.
|
||||
string json = "{\"cacheScope\":\"private\"}";
|
||||
|
||||
var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Null(deserialized.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CacheableResultTypes))]
|
||||
public static void CacheableResult_PaginatedPages_CarryIndependentCachingHints(Type type)
|
||||
{
|
||||
// SEP-2549: each paginated page independently carries its own ttlMs/cacheScope.
|
||||
// Two result instances representing consecutive pages must round-trip distinct hints.
|
||||
var page1 = (ICacheableResult)Activator.CreateInstance(type)!;
|
||||
page1.TimeToLive = TimeSpan.FromMinutes(10);
|
||||
page1.CacheScope = CacheScope.Public;
|
||||
|
||||
var page2 = (ICacheableResult)Activator.CreateInstance(type)!;
|
||||
page2.TimeToLive = TimeSpan.FromSeconds(5);
|
||||
page2.CacheScope = CacheScope.Private;
|
||||
|
||||
var rt1 = (ICacheableResult)JsonSerializer.Deserialize(
|
||||
JsonSerializer.Serialize(page1, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!;
|
||||
var rt2 = (ICacheableResult)JsonSerializer.Deserialize(
|
||||
JsonSerializer.Serialize(page2, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!;
|
||||
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), rt1.TimeToLive);
|
||||
Assert.Equal(CacheScope.Public, rt1.CacheScope);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), rt2.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, rt2.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void CacheableResult_MaxValueTtl_WriteThenRead_IsStableAcrossRoundTrips()
|
||||
{
|
||||
// Writing TimeSpan.MaxValue truncates the sub-millisecond remainder to a whole-millisecond
|
||||
// integer (922337203685477 ms), so the first round-trip is slightly less than MaxValue.
|
||||
// Critically, once written this value is a fixed point: further round-trips do not drift.
|
||||
var first = RoundTrip(new ListToolsResult { TimeToLive = TimeSpan.MaxValue });
|
||||
var second = RoundTrip(new ListToolsResult { TimeToLive = first.TimeToLive });
|
||||
|
||||
Assert.NotEqual(TimeSpan.MaxValue, first.TimeToLive);
|
||||
Assert.Equal(first.TimeToLive, second.TimeToLive);
|
||||
}
|
||||
|
||||
private static ListToolsResult RoundTrip(ListToolsResult result) =>
|
||||
JsonSerializer.Deserialize<ListToolsResult>(
|
||||
JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)!;
|
||||
}
|
||||
Loading…
Reference in a new issue