Skip to content

Commit c021d0a

Browse files
tarekghTarek Mahmoud Sayed
andauthored
Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results (#1623)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
1 parent dbb7a20 commit c021d0a

20 files changed

Lines changed: 1193 additions & 147 deletions

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ public ValueTask<PingResult> PingAsync(
172172
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
173173
/// <returns>A list of all available tools as <see cref="McpClientTool"/> instances.</returns>
174174
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
175+
/// <remarks>
176+
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
177+
/// (<see cref="ListToolsResult.TimeToLive"/> and <see cref="ListToolsResult.CacheScope"/>). To read those hints,
178+
/// use the <see cref="ListToolsAsync(ListToolsRequestParams, CancellationToken)"/> overload, which returns the
179+
/// raw <see cref="ListToolsResult"/> for each page.
180+
/// </remarks>
175181
public async ValueTask<IList<McpClientTool>> ListToolsAsync(
176182
RequestOptions? options = null,
177183
CancellationToken cancellationToken = default)
@@ -256,6 +262,12 @@ public ValueTask<ListToolsResult> ListToolsAsync(
256262
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
257263
/// <returns>A list of all available prompts as <see cref="McpClientPrompt"/> instances.</returns>
258264
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
265+
/// <remarks>
266+
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
267+
/// (<see cref="ListPromptsResult.TimeToLive"/> and <see cref="ListPromptsResult.CacheScope"/>). To read those hints,
268+
/// use the <see cref="ListPromptsAsync(ListPromptsRequestParams, CancellationToken)"/> overload, which returns the
269+
/// raw <see cref="ListPromptsResult"/> for each page.
270+
/// </remarks>
259271
public async ValueTask<IList<McpClientPrompt>> ListPromptsAsync(
260272
RequestOptions? options = null,
261273
CancellationToken cancellationToken = default)
@@ -366,6 +378,12 @@ public ValueTask<GetPromptResult> GetPromptAsync(
366378
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
367379
/// <returns>A list of all available resource templates as <see cref="ResourceTemplate"/> instances.</returns>
368380
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
381+
/// <remarks>
382+
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
383+
/// (<see cref="ListResourceTemplatesResult.TimeToLive"/> and <see cref="ListResourceTemplatesResult.CacheScope"/>). To read those hints,
384+
/// use the <see cref="ListResourceTemplatesAsync(ListResourceTemplatesRequestParams, CancellationToken)"/> overload, which returns the
385+
/// raw <see cref="ListResourceTemplatesResult"/> for each page.
386+
/// </remarks>
369387
public async ValueTask<IList<McpClientResourceTemplate>> ListResourceTemplatesAsync(
370388
RequestOptions? options = null,
371389
CancellationToken cancellationToken = default)
@@ -422,6 +440,12 @@ public ValueTask<ListResourceTemplatesResult> ListResourceTemplatesAsync(
422440
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
423441
/// <returns>A list of all available resources as <see cref="Resource"/> instances.</returns>
424442
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
443+
/// <remarks>
444+
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
445+
/// (<see cref="ListResourcesResult.TimeToLive"/> and <see cref="ListResourcesResult.CacheScope"/>). To read those hints,
446+
/// use the <see cref="ListResourcesAsync(ListResourcesRequestParams, CancellationToken)"/> overload, which returns the
447+
/// raw <see cref="ListResourcesResult"/> for each page.
448+
/// </remarks>
425449
public async ValueTask<IList<McpClientResource>> ListResourcesAsync(
426450
RequestOptions? options = null,
427451
CancellationToken cancellationToken = default)

src/ModelContextProtocol.Core/CompatibilitySuppressions.xml

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,6 @@
134134
<Right>lib/net10.0/ModelContextProtocol.Core.dll</Right>
135135
<IsBaselineSuppression>true</IsBaselineSuppression>
136136
</Suppression>
137-
<Suppression>
138-
<DiagnosticId>CP0001</DiagnosticId>
139-
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
140-
<Left>lib/net10.0/ModelContextProtocol.Core.dll</Left>
141-
<Right>lib/net10.0/ModelContextProtocol.Core.dll</Right>
142-
<IsBaselineSuppression>true</IsBaselineSuppression>
143-
</Suppression>
144137
<Suppression>
145138
<DiagnosticId>CP0001</DiagnosticId>
146139
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>
@@ -295,13 +288,6 @@
295288
<Right>lib/net8.0/ModelContextProtocol.Core.dll</Right>
296289
<IsBaselineSuppression>true</IsBaselineSuppression>
297290
</Suppression>
298-
<Suppression>
299-
<DiagnosticId>CP0001</DiagnosticId>
300-
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
301-
<Left>lib/net8.0/ModelContextProtocol.Core.dll</Left>
302-
<Right>lib/net8.0/ModelContextProtocol.Core.dll</Right>
303-
<IsBaselineSuppression>true</IsBaselineSuppression>
304-
</Suppression>
305291
<Suppression>
306292
<DiagnosticId>CP0001</DiagnosticId>
307293
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>
@@ -456,13 +442,6 @@
456442
<Right>lib/net9.0/ModelContextProtocol.Core.dll</Right>
457443
<IsBaselineSuppression>true</IsBaselineSuppression>
458444
</Suppression>
459-
<Suppression>
460-
<DiagnosticId>CP0001</DiagnosticId>
461-
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
462-
<Left>lib/net9.0/ModelContextProtocol.Core.dll</Left>
463-
<Right>lib/net9.0/ModelContextProtocol.Core.dll</Right>
464-
<IsBaselineSuppression>true</IsBaselineSuppression>
465-
</Suppression>
466445
<Suppression>
467446
<DiagnosticId>CP0001</DiagnosticId>
468447
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>
@@ -617,13 +596,6 @@
617596
<Right>lib/netstandard2.0/ModelContextProtocol.Core.dll</Right>
618597
<IsBaselineSuppression>true</IsBaselineSuppression>
619598
</Suppression>
620-
<Suppression>
621-
<DiagnosticId>CP0001</DiagnosticId>
622-
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
623-
<Left>lib/netstandard2.0/ModelContextProtocol.Core.dll</Left>
624-
<Right>lib/netstandard2.0/ModelContextProtocol.Core.dll</Right>
625-
<IsBaselineSuppression>true</IsBaselineSuppression>
626-
</Suppression>
627599
<Suppression>
628600
<DiagnosticId>CP0001</DiagnosticId>
629601
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
145145
[JsonSerializable(typeof(PingResult))]
146146
[JsonSerializable(typeof(ReadResourceRequestParams))]
147147
[JsonSerializable(typeof(ReadResourceResult))]
148+
[JsonSerializable(typeof(CacheScope))]
148149
[JsonSerializable(typeof(SetLevelRequestParams))]
149150
[JsonSerializable(typeof(SubscribeRequestParams))]
150151
[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: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
return null;
45+
}
46+
47+
// Any non-string token (number, bool, object, array) is an unrecognized hint. Consume the whole
48+
// value, including the contents of an object or array, so the reader is left correctly positioned
49+
// before mapping to null. Skipping is required for container tokens: returning without consuming
50+
// them would leave the reader mispositioned and break deserialization of the enclosing result.
51+
reader.Skip();
52+
return null;
53+
}
54+
55+
public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options)
56+
{
57+
if (value is null)
58+
{
59+
writer.WriteNullValue();
60+
return;
61+
}
62+
63+
writer.WriteStringValue(value switch
64+
{
65+
CacheScope.Public => "public",
66+
CacheScope.Private => "private",
67+
_ => throw new JsonException($"Unsupported {nameof(CacheScope)} value: {value}."),
68+
});
69+
}
70+
}
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. The SDK preserves whatever value the server sent and
38+
/// does not coerce it; a client that receives a negative value should treat it as immediately stale.
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
}

0 commit comments

Comments
 (0)