using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Protocol; /// /// Tests for the SEP-2549 caching hints (ttlMs and cacheScope) carried by /// implementations: the results of tools/list, /// prompts/list, resources/list, resources/templates/list, and /// resources/read. /// public static class CacheableResultTests { public static IEnumerable 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()); Assert.Equal("public", node["cacheScope"]!.GetValue()); 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()); Assert.Equal("private", node["cacheScope"]!.GetValue()); 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("\"public\"", McpJsonUtilities.DefaultOptions)); Assert.Equal(CacheScope.Private, JsonSerializer.Deserialize("\"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( JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)!; }