Skip to content

Commit 51571c6

Browse files
Tarek Mahmoud Sayedhalter73
authored andcommitted
Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results
Implements SEP-2549 "TTL for List Results", which lets servers attach optional caching freshness hints to the five cacheable result types: tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. Protocol changes: - Add ICacheableResult with TimeToLive (serialized as integer-millisecond ttlMs) and CacheScope (serialized as cacheScope). - Add the CacheScope enum (public, private) with lowercase wire values. - Implement the interface on the five cacheable result types. - Register CacheScope for source-generated serialization. Both fields are optional and omitted when unset, so the change is fully backward compatible and requires no capability negotiation. The SDK propagates the values without consuming them. Robustness and security: - ttlMs deserialization clamps out-of-range, fractional, and overflowing values (including positive and negative infinity) to TimeSpan.MinValue or MaxValue instead of throwing, so a malformed or hostile hint cannot break reading of the enclosing result. The shared TimeSpanMillisecondsConverter uses the non-throwing TryGetDouble and clamps by token sign, giving identical behavior on .NET and on .NET Framework (whose number parser reports failure on overflow rather than returning infinity). - cacheScope deserialization tolerates unknown or future values by mapping them to null (treated as the public default) instead of failing the whole result, and matches the known values case-insensitively so a mis-cased "private" is honored rather than silently downgraded to public. Tests: - Serialization, round-trip, omission, and clamping edge cases for ttlMs. - Unknown, partial, and case-insensitive cacheScope handling. - Per-page independence of caching hints for pagination. - End-to-end propagation of hints from server to client. - Regression coverage for the shared converter used by McpTask ttl and pollInterval. - Caching conformance scenario wiring, gated to the conformance build that provides it. Verified across net8.0, net9.0, net10.0, and net472, and under Native AOT publish with no trimming or AOT warnings.
1 parent 1b8dd92 commit 51571c6

18 files changed

Lines changed: 1168 additions & 124 deletions

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
151151
[JsonSerializable(typeof(PingResult))]
152152
[JsonSerializable(typeof(ReadResourceRequestParams))]
153153
[JsonSerializable(typeof(ReadResourceResult))]
154+
[JsonSerializable(typeof(CacheScope))]
154155
[JsonSerializable(typeof(SetLevelRequestParams))]
155156
[JsonSerializable(typeof(SubscribeRequestParams))]
156157
[JsonSerializable(typeof(UnsubscribeRequestParams))]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.Protocol;
4+
5+
/// <summary>
6+
/// Indicates the intended scope of a cached response, analogous to the HTTP
7+
/// <c>Cache-Control: public</c> and <c>Cache-Control: private</c> directives.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// This is used by <see cref="ICacheableResult.CacheScope"/> to control who may cache a
12+
/// response returned by <c>tools/list</c>, <c>prompts/list</c>, <c>resources/list</c>,
13+
/// <c>resources/templates/list</c>, and <c>resources/read</c>.
14+
/// </para>
15+
/// <para>
16+
/// When the field is absent from a response, clients should treat it as <see cref="Public"/>.
17+
/// </para>
18+
/// </remarks>
19+
[JsonConverter(typeof(JsonStringEnumConverter<CacheScope>))]
20+
public enum CacheScope
21+
{
22+
/// <summary>
23+
/// The response does not contain user-specific data. Any client, shared gateway, or caching
24+
/// proxy may store and serve the cached response to any user.
25+
/// </summary>
26+
/// <remarks>
27+
/// This is appropriate for lists of tools, prompts, and resource templates that are identical
28+
/// for all users.
29+
/// </remarks>
30+
[JsonStringEnumMemberName("public")]
31+
Public,
32+
33+
/// <summary>
34+
/// The response contains user-specific data. Only the requesting user's client may cache it.
35+
/// Shared caches (for example, multi-tenant gateways) must not serve the cached response to a
36+
/// different user.
37+
/// </summary>
38+
/// <remarks>
39+
/// This is appropriate for <c>resources/read</c> results that depend on the authenticated user,
40+
/// or for filtered list results that vary per user.
41+
/// </remarks>
42+
[JsonStringEnumMemberName("private")]
43+
Private
44+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ModelContextProtocol.Protocol;
5+
6+
/// <summary>
7+
/// Serializes <see cref="CacheScope"/> caching-scope hints, tolerating unknown or future values on read.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// SEP-2549 introduces <c>cacheScope</c> as a forward-looking caching hint. If a server sends an
12+
/// unrecognized scope string (for example, a value added in a later revision of the specification) or a
13+
/// non-string token, this converter maps it to <see langword="null"/> rather than throwing. This prevents
14+
/// a single unexpected hint from breaking deserialization of the entire result (for example, the whole
15+
/// tool list). A <see langword="null"/> result is the same as an absent field, which clients treat as
16+
/// <see cref="CacheScope.Public"/>.
17+
/// </para>
18+
/// <para>
19+
/// This converter is applied per-property on the cacheable result types. The <see cref="CacheScope"/>
20+
/// enum itself retains a standard string converter for any standalone serialization.
21+
/// </para>
22+
/// </remarks>
23+
internal sealed class CacheScopeConverter : JsonConverter<CacheScope?>
24+
{
25+
public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
26+
{
27+
if (reader.TokenType is JsonTokenType.String)
28+
{
29+
string? value = reader.GetString();
30+
31+
// Match case-insensitively so a non-conforming casing of "private" (a security-relevant hint)
32+
// is honored rather than falling through to null, which clients would treat as "public" and
33+
// could cache user-specific data in a shared cache. Genuinely unknown values still map to null.
34+
if (string.Equals(value, "public", StringComparison.OrdinalIgnoreCase))
35+
{
36+
return CacheScope.Public;
37+
}
38+
39+
if (string.Equals(value, "private", StringComparison.OrdinalIgnoreCase))
40+
{
41+
return CacheScope.Private;
42+
}
43+
}
44+
45+
return null;
46+
}
47+
48+
public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options)
49+
{
50+
if (value is null)
51+
{
52+
writer.WriteNullValue();
53+
return;
54+
}
55+
56+
writer.WriteStringValue(value switch
57+
{
58+
CacheScope.Public => "public",
59+
CacheScope.Private => "private",
60+
_ => throw new JsonException($"Unsupported {nameof(CacheScope)} value: {value}."),
61+
});
62+
}
63+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
namespace ModelContextProtocol.Protocol;
2+
3+
/// <summary>
4+
/// Represents a result that carries time-to-live (TTL) caching hints, allowing clients to cache
5+
/// the response for a period of time before re-fetching.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// This interface corresponds to the <c>CacheableResult</c> type in the Model Context Protocol
10+
/// schema and is implemented by the results of <c>tools/list</c>, <c>prompts/list</c>,
11+
/// <c>resources/list</c>, <c>resources/templates/list</c>, and <c>resources/read</c>.
12+
/// </para>
13+
/// <para>
14+
/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing
15+
/// <c>list_changed</c> and <c>resources/updated</c> notification mechanisms; both can coexist. A
16+
/// relevant notification invalidates a cached response regardless of any remaining TTL.
17+
/// </para>
18+
/// </remarks>
19+
public interface ICacheableResult
20+
{
21+
/// <summary>
22+
/// Gets or sets a hint indicating how long the client may cache this response before re-fetching.
23+
/// </summary>
24+
/// <remarks>
25+
/// <para>
26+
/// The semantics are analogous to the HTTP <c>Cache-Control: max-age</c> directive. The value is
27+
/// serialized as an integer number of milliseconds under the <c>ttlMs</c> JSON property.
28+
/// </para>
29+
/// <para>
30+
/// A value of <see cref="TimeSpan.Zero"/> indicates the response should be considered immediately
31+
/// stale; a positive value indicates the client should consider the response fresh for that
32+
/// duration from the time it was received.
33+
/// </para>
34+
/// <para>
35+
/// When this property is <see langword="null"/> (the field was absent from the response), clients
36+
/// should assume a default of <see cref="TimeSpan.Zero"/> (immediately stale) and rely on their
37+
/// own caching heuristics or notifications. A negative value should likewise be treated as
38+
/// <see cref="TimeSpan.Zero"/>.
39+
/// </para>
40+
/// </remarks>
41+
TimeSpan? TimeToLive { get; set; }
42+
43+
/// <summary>
44+
/// Gets or sets the intended scope of the cached response.
45+
/// </summary>
46+
/// <remarks>
47+
/// <para>
48+
/// When this property is <see langword="null"/> (the field was absent from the response), clients
49+
/// should treat the response as <see cref="Protocol.CacheScope.Public"/>.
50+
/// </para>
51+
/// <para>
52+
/// An unrecognized or future scope value sent by a server (or a non-string value) is tolerated and
53+
/// surfaced as <see langword="null"/> rather than causing deserialization of the whole result to
54+
/// fail, so a single unexpected hint never prevents a client from reading the result.
55+
/// </para>
56+
/// </remarks>
57+
CacheScope? CacheScope { get; set; }
58+
}

src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
1818
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
1919
/// </para>
2020
/// </remarks>
21-
public sealed class ListPromptsResult : PaginatedResult
21+
public sealed class ListPromptsResult : PaginatedResult, ICacheableResult
2222
{
2323
/// <summary>
2424
/// Gets or sets a list of prompts or prompt templates that the server offers.
2525
/// </summary>
2626
[JsonPropertyName("prompts")]
2727
public IList<Prompt> Prompts { get; set; } = [];
28+
29+
/// <inheritdoc />
30+
[JsonPropertyName("ttlMs")]
31+
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
32+
public TimeSpan? TimeToLive { get; set; }
33+
34+
/// <inheritdoc />
35+
[JsonPropertyName("cacheScope")]
36+
[JsonConverter(typeof(CacheScopeConverter))]
37+
public CacheScope? CacheScope { get; set; }
2838
}

src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol;
2020
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
2121
/// </para>
2222
/// </remarks>
23-
public sealed class ListResourceTemplatesResult : PaginatedResult
23+
public sealed class ListResourceTemplatesResult : PaginatedResult, ICacheableResult
2424
{
2525
/// <summary>
2626
/// Gets or sets a list of resource templates that the server offers.
@@ -32,4 +32,14 @@ public sealed class ListResourceTemplatesResult : PaginatedResult
3232
/// </remarks>
3333
[JsonPropertyName("resourceTemplates")]
3434
public IList<ResourceTemplate> ResourceTemplates { get; set; } = [];
35+
36+
/// <inheritdoc />
37+
[JsonPropertyName("ttlMs")]
38+
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
39+
public TimeSpan? TimeToLive { get; set; }
40+
41+
/// <inheritdoc />
42+
[JsonPropertyName("cacheScope")]
43+
[JsonConverter(typeof(CacheScopeConverter))]
44+
public CacheScope? CacheScope { get; set; }
3545
}

src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
1818
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
1919
/// </para>
2020
/// </remarks>
21-
public sealed class ListResourcesResult : PaginatedResult
21+
public sealed class ListResourcesResult : PaginatedResult, ICacheableResult
2222
{
2323
/// <summary>
2424
/// Gets or sets a list of resources that the server offers.
2525
/// </summary>
2626
[JsonPropertyName("resources")]
2727
public IList<Resource> Resources { get; set; } = [];
28+
29+
/// <inheritdoc />
30+
[JsonPropertyName("ttlMs")]
31+
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
32+
public TimeSpan? TimeToLive { get; set; }
33+
34+
/// <inheritdoc />
35+
[JsonPropertyName("cacheScope")]
36+
[JsonConverter(typeof(CacheScopeConverter))]
37+
public CacheScope? CacheScope { get; set; }
2838
}

src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
1818
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
1919
/// </para>
2020
/// </remarks>
21-
public sealed class ListToolsResult : PaginatedResult
21+
public sealed class ListToolsResult : PaginatedResult, ICacheableResult
2222
{
2323
/// <summary>
2424
/// Gets or sets the server's response to a tools/list request from the client.
2525
/// </summary>
2626
[JsonPropertyName("tools")]
2727
public IList<Tool> Tools { get; set; } = [];
28+
29+
/// <inheritdoc />
30+
[JsonPropertyName("ttlMs")]
31+
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
32+
public TimeSpan? TimeToLive { get; set; }
33+
34+
/// <inheritdoc />
35+
[JsonPropertyName("cacheScope")]
36+
[JsonConverter(typeof(CacheScopeConverter))]
37+
public CacheScope? CacheScope { get; set; }
2838
}

src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol;
88
/// <remarks>
99
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
1010
/// </remarks>
11-
public sealed class ReadResourceResult : Result
11+
public sealed class ReadResourceResult : Result, ICacheableResult
1212
{
1313
/// <summary>
1414
/// Gets or sets a list of <see cref="ResourceContents"/> objects that this resource contains.
@@ -20,4 +20,14 @@ public sealed class ReadResourceResult : Result
2020
/// </remarks>
2121
[JsonPropertyName("contents")]
2222
public IList<ResourceContents> Contents { get; set; } = [];
23+
24+
/// <inheritdoc />
25+
[JsonPropertyName("ttlMs")]
26+
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
27+
public TimeSpan? TimeToLive { get; set; }
28+
29+
/// <inheritdoc />
30+
[JsonPropertyName("cacheScope")]
31+
[JsonConverter(typeof(CacheScopeConverter))]
32+
public CacheScope? CacheScope { get; set; }
2333
}

0 commit comments

Comments
 (0)