Skip to content
Open
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
24 changes: 24 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ public ValueTask<PingResult> PingAsync(
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A list of all available tools as <see cref="McpClientTool"/> instances.</returns>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// (<see cref="ListToolsResult.TimeToLive"/> and <see cref="ListToolsResult.CacheScope"/>). To read those hints,
/// use the <see cref="ListToolsAsync(ListToolsRequestParams, CancellationToken)"/> overload, which returns the
/// raw <see cref="ListToolsResult"/> for each page.
/// </remarks>
public async ValueTask<IList<McpClientTool>> ListToolsAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -256,6 +262,12 @@ public ValueTask<ListToolsResult> ListToolsAsync(
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A list of all available prompts as <see cref="McpClientPrompt"/> instances.</returns>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// (<see cref="ListPromptsResult.TimeToLive"/> and <see cref="ListPromptsResult.CacheScope"/>). To read those hints,
/// use the <see cref="ListPromptsAsync(ListPromptsRequestParams, CancellationToken)"/> overload, which returns the
/// raw <see cref="ListPromptsResult"/> for each page.
/// </remarks>
public async ValueTask<IList<McpClientPrompt>> ListPromptsAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -366,6 +378,12 @@ public ValueTask<GetPromptResult> GetPromptAsync(
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A list of all available resource templates as <see cref="ResourceTemplate"/> instances.</returns>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// (<see cref="ListResourceTemplatesResult.TimeToLive"/> and <see cref="ListResourceTemplatesResult.CacheScope"/>). To read those hints,
/// use the <see cref="ListResourceTemplatesAsync(ListResourceTemplatesRequestParams, CancellationToken)"/> overload, which returns the
/// raw <see cref="ListResourceTemplatesResult"/> for each page.
/// </remarks>
public async ValueTask<IList<McpClientResourceTemplate>> ListResourceTemplatesAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -422,6 +440,12 @@ public ValueTask<ListResourceTemplatesResult> ListResourceTemplatesAsync(
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A list of all available resources as <see cref="Resource"/> instances.</returns>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// (<see cref="ListResourcesResult.TimeToLive"/> and <see cref="ListResourcesResult.CacheScope"/>). To read those hints,
/// use the <see cref="ListResourcesAsync(ListResourcesRequestParams, CancellationToken)"/> overload, which returns the
/// raw <see cref="ListResourcesResult"/> for each page.
/// </remarks>
public async ValueTask<IList<McpClientResource>> ListResourcesAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
Expand Down
1 change: 1 addition & 0 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSerializable(typeof(PingResult))]
[JsonSerializable(typeof(ReadResourceRequestParams))]
[JsonSerializable(typeof(ReadResourceResult))]
[JsonSerializable(typeof(CacheScope))]
[JsonSerializable(typeof(SetLevelRequestParams))]
[JsonSerializable(typeof(SubscribeRequestParams))]
[JsonSerializable(typeof(UnsubscribeRequestParams))]
Expand Down
44 changes: 44 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/CacheScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;

/// <summary>
/// Indicates the intended scope of a cached response, analogous to the HTTP
/// <c>Cache-Control: public</c> and <c>Cache-Control: private</c> directives.
/// </summary>
/// <remarks>
/// <para>
/// This is used by <see cref="ICacheableResult.CacheScope"/> to control who may cache a
/// response returned by <c>tools/list</c>, <c>prompts/list</c>, <c>resources/list</c>,
/// <c>resources/templates/list</c>, and <c>resources/read</c>.
/// </para>
/// <para>
/// When the field is absent from a response, clients should treat it as <see cref="Public"/>.
/// </para>
/// </remarks>
[JsonConverter(typeof(JsonStringEnumConverter<CacheScope>))]
public enum CacheScope
{
/// <summary>
/// The response does not contain user-specific data. Any client, shared gateway, or caching
/// proxy may store and serve the cached response to any user.
/// </summary>
/// <remarks>
/// This is appropriate for lists of tools, prompts, and resource templates that are identical
/// for all users.
/// </remarks>
[JsonStringEnumMemberName("public")]
Public,

/// <summary>
/// The response contains user-specific data. Only the requesting user's client may cache it.
/// Shared caches (for example, multi-tenant gateways) must not serve the cached response to a
/// different user.
/// </summary>
/// <remarks>
/// This is appropriate for <c>resources/read</c> results that depend on the authenticated user,
/// or for filtered list results that vary per user.
/// </remarks>
[JsonStringEnumMemberName("private")]
Private
}
70 changes: 70 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;

/// <summary>
/// Serializes <see cref="CacheScope"/> caching-scope hints, tolerating unknown or future values on read.
/// </summary>
/// <remarks>
/// <para>
/// SEP-2549 introduces <c>cacheScope</c> as a forward-looking caching hint. If a server sends an
/// unrecognized scope string (for example, a value added in a later revision of the specification) or a
/// non-string token, this converter maps it to <see langword="null"/> rather than throwing. This prevents
/// a single unexpected hint from breaking deserialization of the entire result (for example, the whole
/// tool list). A <see langword="null"/> result is the same as an absent field, which clients treat as
/// <see cref="CacheScope.Public"/>.
/// </para>
/// <para>
/// This converter is applied per-property on the cacheable result types. The <see cref="CacheScope"/>
/// enum itself retains a standard string converter for any standalone serialization.
/// </para>
/// </remarks>
internal sealed class CacheScopeConverter : JsonConverter<CacheScope?>
{
public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.String)
{
string? value = reader.GetString();

// Match case-insensitively so a non-conforming casing of "private" (a security-relevant hint)
// is honored rather than falling through to null, which clients would treat as "public" and
// could cache user-specific data in a shared cache. Genuinely unknown values still map to null.
if (string.Equals(value, "public", StringComparison.OrdinalIgnoreCase))
{
return CacheScope.Public;
}

if (string.Equals(value, "private", StringComparison.OrdinalIgnoreCase))
{
return CacheScope.Private;
}

return null;
}

// Any non-string token (number, bool, object, array) is an unrecognized hint. Consume the whole
// value, including the contents of an object or array, so the reader is left correctly positioned
// before mapping to null. Skipping is required for container tokens: returning without consuming
// them would leave the reader mispositioned and break deserialization of the enclosing result.
reader.Skip();
return null;
}
Comment thread
tarekgh marked this conversation as resolved.

public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}

writer.WriteStringValue(value switch
{
CacheScope.Public => "public",
CacheScope.Private => "private",
_ => throw new JsonException($"Unsupported {nameof(CacheScope)} value: {value}."),
});
}
}
58 changes: 58 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace ModelContextProtocol.Protocol;

/// <summary>
/// Represents a result that carries time-to-live (TTL) caching hints, allowing clients to cache
/// the response for a period of time before re-fetching.
/// </summary>
/// <remarks>
/// <para>
/// This interface corresponds to the <c>CacheableResult</c> type in the Model Context Protocol
/// schema and is implemented by 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>.
/// </para>
/// <para>
/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing
/// <c>list_changed</c> and <c>resources/updated</c> notification mechanisms; both can coexist. A
/// relevant notification invalidates a cached response regardless of any remaining TTL.
/// </para>
/// </remarks>
public interface ICacheableResult
{
/// <summary>
/// Gets or sets a hint indicating how long the client may cache this response before re-fetching.
/// </summary>
/// <remarks>
/// <para>
/// The semantics are analogous to the HTTP <c>Cache-Control: max-age</c> directive. The value is
/// serialized as an integer number of milliseconds under the <c>ttlMs</c> JSON property.
/// </para>
/// <para>
/// A value of <see cref="TimeSpan.Zero"/> indicates the response should be considered immediately
/// stale; a positive value indicates the client should consider the response fresh for that
/// duration from the time it was received.
/// </para>
/// <para>
/// When this property is <see langword="null"/> (the field was absent from the response), clients
/// should assume a default of <see cref="TimeSpan.Zero"/> (immediately stale) and rely on their
/// own caching heuristics or notifications. The SDK preserves whatever value the server sent and
/// does not coerce it; a client that receives a negative value should treat it as immediately stale.
/// </para>
/// </remarks>
TimeSpan? TimeToLive { get; set; }

/// <summary>
/// Gets or sets the intended scope of the cached response.
/// </summary>
/// <remarks>
/// <para>
/// When this property is <see langword="null"/> (the field was absent from the response), clients
/// should treat the response as <see cref="Protocol.CacheScope.Public"/>.
/// </para>
/// <para>
/// An unrecognized or future scope value sent by a server (or a non-string value) is tolerated and
/// surfaced as <see langword="null"/> rather than causing deserialization of the whole result to
/// fail, so a single unexpected hint never prevents a client from reading the result.
/// </para>
/// </remarks>
CacheScope? CacheScope { get; set; }
}
12 changes: 11 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
/// </para>
/// </remarks>
public sealed class ListPromptsResult : PaginatedResult
public sealed class ListPromptsResult : PaginatedResult, ICacheableResult
{
/// <summary>
/// Gets or sets a list of prompts or prompt templates that the server offers.
/// </summary>
[JsonPropertyName("prompts")]
public IList<Prompt> Prompts { get; set; } = [];

/// <inheritdoc />
[JsonPropertyName("ttlMs")]
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
public TimeSpan? TimeToLive { get; set; }

/// <inheritdoc />
[JsonPropertyName("cacheScope")]
[JsonConverter(typeof(CacheScopeConverter))]
public CacheScope? CacheScope { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol;
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
/// </para>
/// </remarks>
public sealed class ListResourceTemplatesResult : PaginatedResult
public sealed class ListResourceTemplatesResult : PaginatedResult, ICacheableResult
{
/// <summary>
/// Gets or sets a list of resource templates that the server offers.
Expand All @@ -32,4 +32,14 @@ public sealed class ListResourceTemplatesResult : PaginatedResult
/// </remarks>
[JsonPropertyName("resourceTemplates")]
public IList<ResourceTemplate> ResourceTemplates { get; set; } = [];

/// <inheritdoc />
[JsonPropertyName("ttlMs")]
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
public TimeSpan? TimeToLive { get; set; }

/// <inheritdoc />
[JsonPropertyName("cacheScope")]
[JsonConverter(typeof(CacheScopeConverter))]
public CacheScope? CacheScope { get; set; }
}
12 changes: 11 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
/// </para>
/// </remarks>
public sealed class ListResourcesResult : PaginatedResult
public sealed class ListResourcesResult : PaginatedResult, ICacheableResult
{
/// <summary>
/// Gets or sets a list of resources that the server offers.
/// </summary>
[JsonPropertyName("resources")]
public IList<Resource> Resources { get; set; } = [];

/// <inheritdoc />
[JsonPropertyName("ttlMs")]
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
public TimeSpan? TimeToLive { get; set; }

/// <inheritdoc />
[JsonPropertyName("cacheScope")]
[JsonConverter(typeof(CacheScopeConverter))]
public CacheScope? CacheScope { get; set; }
}
12 changes: 11 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
/// </para>
/// </remarks>
public sealed class ListToolsResult : PaginatedResult
public sealed class ListToolsResult : PaginatedResult, ICacheableResult

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new TimeToLive/CacheScope properties are only reachable through the raw ListToolsAsync(ListToolsRequestParams, CancellationToken) overload. The auto-paginating ListToolsAsync(RequestOptions?, CancellationToken) overload (which is probably what most consumers reach for) returns IList<McpClientTool> and drops the per-page hints entirely. Same story for ListPromptsAsync, ListResourcesAsync, and ListResourceTemplatesAsync. ReadResourceAsync is fine since it isn't paginated.

Do we need any follow up issues for this? I can think of a couple things we might want to do. One would be to expose the TimeToLive/CacheScope to the auto-paginating overload. The second might be to take advantage of the caching opportunities in the client when given a non-zero ttol

In the meantime, adding a small <remarks> note on the convenience overloads pointing at the raw one probably makes sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a <remarks> note to all four auto-paginating overloads (ListToolsAsync, ListPromptsAsync, ListResourcesAsync, ListResourceTemplatesAsync) pointing callers at the raw single-page overload, which returns the result type that carries TimeToLive/CacheScope. ReadResourceAsync is left alone since it isn't paginated.

For the two larger ideas, I filed follow-ups and kept them out of this PR:

I'd rather not fold these into this PR. The auto-paginating overloads aggregate multiple pages into a single IList<...>, and each page carries its own ttlMs/cacheScope, so exposing them is a real API-design decision (which value do you surface for an aggregated list, and do we change the return type, add out params, or introduce a new wrapper?) rather than a quick addition. Client-side caching is effectively a new opt-in subsystem (cache storage, TTL expiry, public/private scope semantics, invalidation, concurrency). Keeping this PR focused on the SEP-2549 wire-format conformance keeps the change small and easy to review, and the <remarks> already give callers the path to the hints today.

{
/// <summary>
/// Gets or sets the server's response to a tools/list request from the client.
/// </summary>
[JsonPropertyName("tools")]
public IList<Tool> Tools { get; set; } = [];

/// <inheritdoc />
[JsonPropertyName("ttlMs")]
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
public TimeSpan? TimeToLive { get; set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and cacheScope are now required fields with the new protocol version. I assume we want to send defaults if this is unspecified with the draft protocol version selected. I imagine this would be ttlMs: 0 and cacheScope: "private" (immediately stale, not shareable), which preserves today's "don't cache" behavior.

Can we add the logic to send this instead of omitting the fields when they aren't customized? We currently use DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull for MCP DTOs in McpJsonUtilities, but that violates the spec.

And should we do something on the client if a server claiming to support the new protocol omits these fields? Maybe log a warning rather than throw to avoid breaking people unnecessarily when talking to non-conformant servers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The server now injects the conservative defaults (ttlMs: 0, cacheScope: "private" -> immediately stale, not shareable) whenever a handler leaves them unset, so the required fields are always present on the wire and today's "don't cache" behavior is preserved. Any value a handler or filter supplies is left untouched.

A couple of notes on the route we took:

  • Injection is done by wrapping the handler in SetHandler for the five cacheable result types (tools/list, prompts/list, resources/list, resources/templates/list, resources/read), rather than changing the JSON ignore condition. This keeps DefaultIgnoreCondition = WhenWritingNull intact for the rest of the DTOs and only forces the fields to be written for the results the spec actually requires them on. The check is a one-time ICacheableResult.IsAssignableFrom at registration, so the hot paths are untouched and it stays AOT-safe.

  • We kept TimeToLive and CacheScope nullable rather than making them non-nullable with default initializers. The send and read directions need different defaults: on send the safe default is private (the SEP notes there is no safe default when interpreting older servers, and filtered/user-specific results shouldn't be assumed shareable), but on read an absent cacheScope must be treated as public per the spec. Since the same result type is shared by both client and server, a single non-nullable property with one initializer can't express both. Nullable + server-side injection gives us "server always emits a value, client can still observe absence from another server" without splitting each result into separate send/receive types. (default(CacheScope) is also Public, which would be the wrong default to bake into the type for the send path.)

On the client-side warning when a server advertising the new version omits these fields: that's a reasonable addition; I'd prefer to track it as a follow-up so it can be applied consistently across all the list/read paths.


/// <inheritdoc />
[JsonPropertyName("cacheScope")]
[JsonConverter(typeof(CacheScopeConverter))]
public CacheScope? CacheScope { get; set; }
}
12 changes: 11 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol;
/// <remarks>
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
/// </remarks>
public sealed class ReadResourceResult : Result
public sealed class ReadResourceResult : Result, ICacheableResult
{
/// <summary>
/// Gets or sets a list of <see cref="ResourceContents"/> objects that this resource contains.
Expand All @@ -20,4 +20,14 @@ public sealed class ReadResourceResult : Result
/// </remarks>
[JsonPropertyName("contents")]
public IList<ResourceContents> Contents { get; set; } = [];

/// <inheritdoc />
[JsonPropertyName("ttlMs")]
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
public TimeSpan? TimeToLive { get; set; }

/// <inheritdoc />
[JsonPropertyName("cacheScope")]
[JsonConverter(typeof(CacheScopeConverter))]
public CacheScope? CacheScope { get; set; }
}
Loading
Loading