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
13 changed files with 1193 additions and 147 deletions
|
|
@ -172,6 +172,12 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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)
|
||||
|
|
@ -256,6 +262,12 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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)
|
||||
|
|
@ -366,6 +378,12 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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)
|
||||
|
|
@ -422,6 +440,12 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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)
|
||||
|
|
|
|||
|
|
@ -134,13 +134,6 @@
|
|||
<Right>lib/net10.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
|
||||
<Left>lib/net10.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net10.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>
|
||||
|
|
@ -295,13 +288,6 @@
|
|||
<Right>lib/net8.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
|
||||
<Left>lib/net8.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net8.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>
|
||||
|
|
@ -456,13 +442,6 @@
|
|||
<Right>lib/net9.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
|
||||
<Left>lib/net9.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net9.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>
|
||||
|
|
@ -617,13 +596,6 @@
|
|||
<Right>lib/netstandard2.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter</Target>
|
||||
<Left>lib/netstandard2.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/netstandard2.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0001</DiagnosticId>
|
||||
<Target>T:ModelContextProtocol.Protocol.ToolExecution</Target>
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ public static partial class McpJsonUtilities
|
|||
[JsonSerializable(typeof(PingResult))]
|
||||
[JsonSerializable(typeof(ReadResourceRequestParams))]
|
||||
[JsonSerializable(typeof(ReadResourceResult))]
|
||||
[JsonSerializable(typeof(CacheScope))]
|
||||
[JsonSerializable(typeof(SetLevelRequestParams))]
|
||||
[JsonSerializable(typeof(SubscribeRequestParams))]
|
||||
[JsonSerializable(typeof(UnsubscribeRequestParams))]
|
||||
|
|
|
|||
44
src/ModelContextProtocol.Core/Protocol/CacheScope.cs
Normal file
44
src/ModelContextProtocol.Core/Protocol/CacheScope.cs
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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
src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs
Normal file
58
src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <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; }
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("cacheScope")]
|
||||
[JsonConverter(typeof(CacheScopeConverter))]
|
||||
public CacheScope? CacheScope { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a JSON converter for <see cref="TimeSpan"/> that serializes as integer milliseconds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This converter serializes TimeSpan values as the total number of milliseconds (as an integer),
|
||||
/// and deserializes integer millisecond values back to TimeSpan. System.Text.Json automatically
|
||||
/// handles nullable TimeSpan properties using this converter. Millisecond values that fall outside
|
||||
/// the range representable by <see cref="TimeSpan"/> are clamped to
|
||||
/// <see cref="TimeSpan.MinValue"/>/<see cref="TimeSpan.MaxValue"/> rather than throwing, so an
|
||||
/// oversized or malformed hint can never break deserialization of the enclosing result.
|
||||
/// </remarks>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public sealed class TimeSpanMillisecondsConverter : JsonConverter<TimeSpan>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType is JsonTokenType.Number)
|
||||
{
|
||||
if (reader.TryGetInt64(out long milliseconds))
|
||||
{
|
||||
return FromMillisecondsClamped(milliseconds);
|
||||
}
|
||||
|
||||
// Non-integer value: fractional, or a magnitude too large to represent. Use the non-throwing
|
||||
// TryGetDouble so an out-of-range exponent never breaks deserialization. Note that different
|
||||
// runtimes disagree on out-of-range doubles: in-box .NET returns +/-Infinity, whereas .NET
|
||||
// Framework's parser reports failure. Handle both so behavior is identical everywhere.
|
||||
if (reader.TryGetDouble(out double value))
|
||||
{
|
||||
if (double.IsPositiveInfinity(value))
|
||||
{
|
||||
return TimeSpan.MaxValue;
|
||||
}
|
||||
|
||||
if (double.IsNegativeInfinity(value))
|
||||
{
|
||||
return TimeSpan.MinValue;
|
||||
}
|
||||
|
||||
return FromTicksClamped(value * TimeSpan.TicksPerMillisecond);
|
||||
}
|
||||
|
||||
// The runtime could not represent the number as a double at all (e.g. .NET Framework on an
|
||||
// overflowing exponent). Clamp by the sign of the raw token.
|
||||
return IsNegativeNumberToken(ref reader) ? TimeSpan.MinValue : TimeSpan.MaxValue;
|
||||
}
|
||||
|
||||
throw new JsonException($"Unable to convert {reader.TokenType} to TimeSpan.");
|
||||
}
|
||||
|
||||
private static bool IsNegativeNumberToken(ref Utf8JsonReader reader)
|
||||
{
|
||||
ReadOnlySpan<byte> token = reader.HasValueSequence ? reader.ValueSequence.First.Span : reader.ValueSpan;
|
||||
return !token.IsEmpty && token[0] == (byte)'-';
|
||||
}
|
||||
|
||||
// Largest whole-millisecond count representable as a TimeSpan (TimeSpan.MaxValue.Ticks / TicksPerMillisecond).
|
||||
private const long MaxWholeMilliseconds = long.MaxValue / TimeSpan.TicksPerMillisecond;
|
||||
|
||||
// Converts an integer millisecond count to a TimeSpan, clamping out-of-range values to
|
||||
// TimeSpan.MinValue/MaxValue instead of throwing. A malformed or oversized hint (for example a
|
||||
// hostile or buggy server returning an enormous ttlMs) must never break deserialization of the
|
||||
// whole result; per SEP-2549 clients should handle unexpected TTL values gracefully.
|
||||
private static TimeSpan FromMillisecondsClamped(long milliseconds)
|
||||
{
|
||||
if (milliseconds > MaxWholeMilliseconds)
|
||||
{
|
||||
return TimeSpan.MaxValue;
|
||||
}
|
||||
|
||||
if (milliseconds < -MaxWholeMilliseconds)
|
||||
{
|
||||
return TimeSpan.MinValue;
|
||||
}
|
||||
|
||||
return TimeSpan.FromTicks(milliseconds * TimeSpan.TicksPerMillisecond);
|
||||
}
|
||||
|
||||
// Converts a (possibly fractional or out-of-range) tick count to a TimeSpan, clamping instead of
|
||||
// throwing. The caller passes a value already scaled into tick-space (milliseconds * TicksPerMillisecond)
|
||||
// because TimeSpan is backed by a long tick count, so comparing against long.MaxValue/MinValue is the
|
||||
// exact test for whether the final (long) cast would overflow. The comparisons MUST run before that cast:
|
||||
// double arithmetic saturates to +/-Infinity on overflow rather than throwing, and both infinities fall
|
||||
// into the clamp branches here (+Infinity >= long.MaxValue, -Infinity <= long.MinValue); if Infinity
|
||||
// instead reached "(long)ticks" the unchecked conversion would silently yield long.MinValue. NaN is not
|
||||
// reachable from valid JSON (the only multiplicand is a non-zero constant) but is mapped to zero
|
||||
// defensively so a non-numeric hint can never break deserialization.
|
||||
private static TimeSpan FromTicksClamped(double ticks)
|
||||
{
|
||||
if (double.IsNaN(ticks))
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (ticks >= long.MaxValue)
|
||||
{
|
||||
return TimeSpan.MaxValue;
|
||||
}
|
||||
|
||||
if (ticks <= long.MinValue)
|
||||
{
|
||||
return TimeSpan.MinValue;
|
||||
}
|
||||
|
||||
return TimeSpan.FromTicks((long)ticks);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteNumberValue((long)value.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
|
|
@ -1275,6 +1275,27 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
JsonTypeInfo<TParams> requestTypeInfo,
|
||||
JsonTypeInfo<TResult> responseTypeInfo)
|
||||
{
|
||||
// SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list,
|
||||
// resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields.
|
||||
// When a handler leaves them unset, fill in conservative defaults (immediately stale and not
|
||||
// shareable) so the wire form always carries the fields while preserving today's "don't cache"
|
||||
// behavior. Any value supplied by the handler or a filter is left untouched.
|
||||
if (typeof(ICacheableResult).IsAssignableFrom(typeof(TResult)))
|
||||
{
|
||||
var innerHandler = handler;
|
||||
handler = async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
if (result is ICacheableResult cacheable)
|
||||
{
|
||||
cacheable.TimeToLive ??= TimeSpan.Zero;
|
||||
cacheable.CacheScope ??= CacheScope.Private;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
_requestHandlers.Set(method,
|
||||
(request, jsonRpcRequest, cancellationToken) =>
|
||||
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
|
||||
|
|
|
|||
Loading…
Reference in a new issue