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
20 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),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Utils;
|
||||
|
||||
|
|
@ -78,16 +80,25 @@ public static class NodeHelpers
|
|||
/// </summary>
|
||||
/// <param name="binaryName">The name of the binary in node_modules/.bin (e.g. "conformance").</param>
|
||||
/// <param name="arguments">The arguments to pass to the binary.</param>
|
||||
/// <param name="appendProtocolVersionFromEnv">
|
||||
/// When <see langword="true"/> (the default) and the MCP_CONFORMANCE_PROTOCOL_VERSION
|
||||
/// environment variable is set, a "--spec-version <value>" argument is appended.
|
||||
/// Pass <see langword="false"/> for scenarios that pin their own spec version (e.g. the
|
||||
/// draft-only caching scenario) to avoid a conflicting duplicate flag.
|
||||
/// </param>
|
||||
/// <returns>A configured ProcessStartInfo for running the binary.</returns>
|
||||
public static ProcessStartInfo ConformanceTestStartInfo(string arguments)
|
||||
public static ProcessStartInfo ConformanceTestStartInfo(string arguments, bool appendProtocolVersionFromEnv = true)
|
||||
{
|
||||
EnsureNpmDependenciesInstalled();
|
||||
|
||||
// If MCP_CONFORMANCE_PROTOCOL_VERSION is set, pass it as --spec-version to the runner.
|
||||
var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION");
|
||||
if (!string.IsNullOrEmpty(protocolVersion))
|
||||
if (appendProtocolVersionFromEnv)
|
||||
{
|
||||
arguments += $" --spec-version {protocolVersion}";
|
||||
var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION");
|
||||
if (!string.IsNullOrEmpty(protocolVersion))
|
||||
{
|
||||
arguments += $" --spec-version {protocolVersion}";
|
||||
}
|
||||
}
|
||||
|
||||
var repoRoot = FindRepoRoot();
|
||||
|
|
@ -168,41 +179,197 @@ public static class NodeHelpers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the SEP-2243 conformance scenarios are available by reading
|
||||
/// the conformance package version from the repo's package.json.
|
||||
/// Checks whether the SEP-2243 conformance scenarios are available, by reading the
|
||||
/// <em>installed</em> conformance package version from node_modules.
|
||||
/// The http-standard-headers, http-custom-headers, http-invalid-tool-headers,
|
||||
/// http-header-validation, and http-custom-header-server-validation scenarios
|
||||
/// require a conformance package version that includes SEP-2243 support.
|
||||
/// http-header-validation, and http-custom-header-server-validation scenarios were
|
||||
/// introduced in conformance package 0.2.0. Reading the installed version (rather than
|
||||
/// the pinned version in package.json) means this also returns <see langword="true"/>
|
||||
/// when a newer private build has been installed locally via
|
||||
/// <c>npm install --no-save <path-to-conformance></c>.
|
||||
/// </summary>
|
||||
public static bool HasSep2243Scenarios()
|
||||
public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance
|
||||
/// PR #275) is available, by reading the <em>installed</em> conformance package version
|
||||
/// from node_modules. The caching scenario was introduced in conformance package 0.2.0.
|
||||
/// Reading the installed version (rather than the pinned version in package.json) means
|
||||
/// this also returns <see langword="true"/> when a newer private build has been installed
|
||||
/// locally via <c>npm install --no-save <path-to-conformance></c>.
|
||||
/// </summary>
|
||||
public static bool HasCachingScenario() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the conformance package installed in node_modules
|
||||
/// has a version greater than or equal to <paramref name="minimumVersion"/>.
|
||||
/// </summary>
|
||||
private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion)
|
||||
{
|
||||
var version = GetInstalledConformanceVersion();
|
||||
return version is not null && version >= minimumVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the version of the conformance package actually installed in node_modules,
|
||||
/// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be
|
||||
/// parsed as a <see cref="Version"/>. Returns <see langword="null"/> if it cannot be
|
||||
/// determined.
|
||||
/// </summary>
|
||||
private static Version? GetInstalledConformanceVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var packageJsonPath = Path.Combine(repoRoot, "package.json");
|
||||
var packageJsonPath = Path.Combine(
|
||||
repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json");
|
||||
|
||||
// This is a skip gate for version-conditional conformance scenarios, so it must stay
|
||||
// side-effect-free. If the conformance package isn't installed, report no version (the
|
||||
// scenario is simply gated off); the actual scenario run path restores npm dependencies
|
||||
// separately via ConformanceTestStartInfo.
|
||||
if (!File.Exists(packageJsonPath))
|
||||
{
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
|
||||
if (json.RootElement.TryGetProperty("dependencies", out var deps) &&
|
||||
deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement))
|
||||
using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
|
||||
if (json.RootElement.TryGetProperty("version", out var versionElement) &&
|
||||
versionElement.GetString() is { } versionStr)
|
||||
{
|
||||
var versionStr = versionElement.GetString();
|
||||
if (versionStr is not null && Version.TryParse(versionStr, out var version))
|
||||
// Strip any prerelease/build suffix so System.Version can parse it.
|
||||
var core = versionStr.Split('-', '+')[0];
|
||||
if (Version.TryParse(core, out var version))
|
||||
{
|
||||
// SEP-2243 scenarios are expected in conformance package >= 0.2.0
|
||||
return version >= new Version(0, 2, 0);
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the conformance runner ("conformance <arguments>") in server mode and returns
|
||||
/// whether it succeeded along with the captured stdout/stderr. Centralizes the process
|
||||
/// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback)
|
||||
/// shared by the server-side conformance tests.
|
||||
/// </summary>
|
||||
/// <param name="arguments">Arguments to pass to the conformance runner.</param>
|
||||
/// <param name="onLine">Optional callback invoked for each captured stdout/stderr line.</param>
|
||||
/// <param name="appendProtocolVersionFromEnv">
|
||||
/// Forwarded to <see cref="ConformanceTestStartInfo(string, bool)"/>.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Token used to cancel the run.</param>
|
||||
public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync(
|
||||
string arguments,
|
||||
Action<string>? onLine = null,
|
||||
bool appendProtocolVersionFromEnv = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv);
|
||||
|
||||
var outputBuilder = new StringBuilder();
|
||||
var errorBuilder = new StringBuilder();
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
|
||||
// Protect callbacks with try/catch so a callback that throws on a background thread
|
||||
// (e.g. ITestOutputHelper after the test completes) does not crash the test host.
|
||||
DataReceivedEventHandler outputHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
try { onLine?.Invoke(e.Data); } catch { }
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
DataReceivedEventHandler errorHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
try { onLine?.Invoke(e.Data); } catch { }
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += outputHandler;
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromMinutes(5));
|
||||
try
|
||||
{
|
||||
#if NET
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
#else
|
||||
// net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill
|
||||
// extension and surface a timeout the same way the modern path does.
|
||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(5));
|
||||
if (!process.HasExited)
|
||||
{
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
#if NET
|
||||
process.Kill(entireProcessTree: true);
|
||||
#else
|
||||
process.Kill();
|
||||
#endif
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
return (
|
||||
false,
|
||||
outputBuilder.ToString(),
|
||||
errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed.");
|
||||
}
|
||||
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
|
||||
var stdoutText = outputBuilder.ToString();
|
||||
var stderrText = errorBuilder.ToString();
|
||||
|
||||
// The Node.js conformance runner can crash during cleanup on Windows with a libuv
|
||||
// assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit
|
||||
// code even though every conformance check passed. When that happens, fall back to
|
||||
// parsing the "Test Results:" summary in stdout to decide success.
|
||||
bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText);
|
||||
|
||||
return (success, stdoutText, stderrText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the conformance runner output for a "Test Results:" line such as
|
||||
/// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed
|
||||
/// and none failed.
|
||||
/// </summary>
|
||||
private static bool ConformanceOutputIndicatesSuccess(string output)
|
||||
{
|
||||
// Match lines like "Passed: 3/3, 0 failed, 0 warnings"
|
||||
var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed");
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int passed = int.Parse(match.Groups[1].Value);
|
||||
int total = int.Parse(match.Groups[2].Value);
|
||||
int failed = int.Parse(match.Groups[3].Value);
|
||||
|
||||
return passed == total && failed == 0 && total > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
using System.Diagnostics;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.ConformanceTests;
|
||||
|
||||
/// <summary>
|
||||
/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the draft
|
||||
/// SEP-2549 "caching" conformance scenario requires. Started on demand (so it is not bound
|
||||
/// when the caching test is skipped) and torn down via <see cref="DisposeAsync"/>. Uses a
|
||||
/// distinct port range from the stateful <c>ConformanceServerFixture</c> (3001/3002/3003) so
|
||||
/// the two can run in parallel without TCP conflicts.
|
||||
/// </summary>
|
||||
internal sealed class StatelessConformanceServer : IAsyncDisposable
|
||||
{
|
||||
// Use different ports for each target framework to allow parallel execution across the
|
||||
// multi-targeted test processes, offset from a caller-supplied base port so independent
|
||||
// stateless servers (e.g. caching vs. SEP-2243) do not collide. net10.0 -> +0,
|
||||
// net9.0 -> +1, net8.0 -> +2.
|
||||
private static int GetPortForTargetFramework(int basePort)
|
||||
{
|
||||
var testBinaryDir = AppContext.BaseDirectory;
|
||||
var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));
|
||||
|
||||
var offset = targetFramework switch
|
||||
{
|
||||
"net10.0" => 0,
|
||||
"net9.0" => 1,
|
||||
"net8.0" => 2,
|
||||
_ => 0 // Default fallback
|
||||
};
|
||||
|
||||
return basePort + offset;
|
||||
}
|
||||
|
||||
private readonly Task _serverTask;
|
||||
private readonly CancellationTokenSource _serverCts;
|
||||
|
||||
public string ServerUrl { get; }
|
||||
|
||||
private StatelessConformanceServer(string serverUrl, Task serverTask, CancellationTokenSource serverCts)
|
||||
{
|
||||
ServerUrl = serverUrl;
|
||||
_serverTask = serverTask;
|
||||
_serverCts = serverCts;
|
||||
}
|
||||
|
||||
public static async Task<StatelessConformanceServer> StartAsync(CancellationToken cancellationToken, int basePort = 3011)
|
||||
{
|
||||
var serverUrl = $"http://localhost:{GetPortForTargetFramework(basePort)}";
|
||||
var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
// "--stateless true" opts this server instance into the SEP-2575 stateless lifecycle
|
||||
// (see ConformanceServer.Program), without mutating process-wide environment state.
|
||||
var serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(
|
||||
["--urls", serverUrl, "--stateless", "true"], cancellationToken: serverCts.Token));
|
||||
|
||||
// Wait for the server to be ready (retry for up to 30 seconds).
|
||||
var timeout = TimeSpan.FromSeconds(30);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout };
|
||||
|
||||
while (stopwatch.Elapsed < timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
await httpClient.GetAsync($"{serverUrl}/health", cancellationToken);
|
||||
return new StatelessConformanceServer(serverUrl, serverTask, serverCts);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// Connection refused means the server is not ready yet.
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Timeout means the server might be processing; give it more time.
|
||||
}
|
||||
|
||||
await Task.Delay(500, cancellationToken);
|
||||
}
|
||||
|
||||
serverCts.Cancel();
|
||||
serverCts.Dispose();
|
||||
throw new InvalidOperationException("Stateless ConformanceServer failed to start within the timeout period");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_serverCts.Cancel();
|
||||
try
|
||||
{
|
||||
await _serverTask.WaitAsync(TestConstants.DefaultTimeout);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions during shutdown.
|
||||
}
|
||||
_serverCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the official MCP conformance "caching" scenario (SEP-2549: TTL for List Results,
|
||||
/// added in conformance PR #275) against the SDK's ConformanceServer, verifying that the SDK
|
||||
/// correctly emits the <c>ttlMs</c> and <c>cacheScope</c> caching hints on cacheable results
|
||||
/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The scenario is draft-only (introduced in DRAFT-2026-v1) and uses the stateless lifecycle.
|
||||
/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when
|
||||
/// running against the currently-pinned package, so it activates automatically once a
|
||||
/// conformance package containing the caching scenario is installed (including a local private
|
||||
/// build installed via <c>npm install --no-save <path-to-conformance></c>). The stateless
|
||||
/// server is started only after the gates pass, so a skipped run binds no port.
|
||||
/// </remarks>
|
||||
public class CachingConformanceTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunCachingConformanceTest()
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasCachingScenario(),
|
||||
"SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0).");
|
||||
|
||||
await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// The caching scenario only exists in the draft spec, so pin the spec version
|
||||
// explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a
|
||||
// conflicting duplicate --spec-version flag).
|
||||
var result = await NodeHelpers.RunServerConformanceAsync(
|
||||
$"server --url {server.ServerUrl} --scenario caching --spec-version DRAFT-2026-v1",
|
||||
line => { try { output.WriteLine(line); } catch { } },
|
||||
appendProtocolVersionFromEnv: false,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"SEP-2549 caching conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ public class ClientConformanceTests
|
|||
}
|
||||
|
||||
// HTTP Standardization (SEP-2243)
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios not yet available.", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[InlineData("http-standard-headers")]
|
||||
[InlineData("http-custom-headers")]
|
||||
[InlineData("http-invalid-tool-headers")]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.ConformanceTests;
|
||||
|
|
@ -37,8 +35,10 @@ public class ConformanceServerFixture : IAsyncLifetime
|
|||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_serverCts = new CancellationTokenSource();
|
||||
// Explicitly pass "--stateless false" so this stateful fixture is immune to a globally
|
||||
// set MCP_CONFORMANCE_STATELESS environment variable (the command-line switch wins).
|
||||
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(
|
||||
["--urls", ServerUrl], cancellationToken: _serverCts.Token));
|
||||
["--urls", ServerUrl, "--stateless", "false"], cancellationToken: _serverCts.Token));
|
||||
|
||||
// Wait for server to be ready (retry for up to 30 seconds)
|
||||
var timeout = TimeSpan.FromSeconds(30);
|
||||
|
|
@ -139,9 +139,19 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
public async Task RunConformanceTest_HttpHeaderValidation()
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
|
||||
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation");
|
||||
// SEP-2243 is a draft (DRAFT-2026-v1) scenario that uses the stateless lifecycle, so it
|
||||
// requires a stateless server (a stateful server rejects the un-initialized list/call
|
||||
// requests with JSON-RPC -32000). Use a dedicated port range so it never collides with
|
||||
// the stateful class fixture (300x) or the caching stateless server (301x).
|
||||
await using var server = await StatelessConformanceServer.StartAsync(
|
||||
TestContext.Current.CancellationToken, basePort: 3021);
|
||||
|
||||
var result = await RunStatelessConformanceTestAsync(
|
||||
$"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1");
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
|
|
@ -151,9 +161,15 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
public async Task RunConformanceTest_HttpCustomHeaderServerValidation()
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
|
||||
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation");
|
||||
await using var server = await StatelessConformanceServer.StartAsync(
|
||||
TestContext.Current.CancellationToken, basePort: 3024);
|
||||
|
||||
var result = await RunStatelessConformanceTestAsync(
|
||||
$"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version DRAFT-2026-v1");
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
|
|
@ -189,94 +205,20 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
|
||||
private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
|
||||
{
|
||||
var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments);
|
||||
|
||||
var outputBuilder = new StringBuilder();
|
||||
var errorBuilder = new StringBuilder();
|
||||
|
||||
var process = new Process { StartInfo = startInfo };
|
||||
|
||||
// Protect callbacks with try/catch to prevent ITestOutputHelper from
|
||||
// throwing on a background thread if events arrive after the test completes.
|
||||
DataReceivedEventHandler outputHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
try { output.WriteLine(e.Data); } catch { }
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
DataReceivedEventHandler errorHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
try { output.WriteLine(e.Data); } catch { }
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += outputHandler;
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
return (
|
||||
Success: false,
|
||||
Output: outputBuilder.ToString(),
|
||||
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
|
||||
);
|
||||
}
|
||||
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
|
||||
var stdoutText = outputBuilder.ToString();
|
||||
var stderrText = errorBuilder.ToString();
|
||||
|
||||
// The Node.js conformance runner can crash during cleanup on Windows with a libuv
|
||||
// assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit
|
||||
// code even though every conformance check passed. When that happens, fall back to
|
||||
// parsing the "Test Results:" summary in stdout to decide success.
|
||||
bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText);
|
||||
|
||||
return (
|
||||
Success: success,
|
||||
Output: stdoutText,
|
||||
Error: stderrText
|
||||
);
|
||||
return await NodeHelpers.RunServerConformanceAsync(
|
||||
arguments,
|
||||
line => { try { output.WriteLine(line); } catch { } },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the conformance runner output for a "Test Results:" line such as
|
||||
/// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed
|
||||
/// and none failed.
|
||||
/// </summary>
|
||||
private static bool ConformanceOutputIndicatesSuccess(string output)
|
||||
// For draft scenarios that pin --spec-version explicitly, suppress the
|
||||
// MCP_CONFORMANCE_PROTOCOL_VERSION override so a duplicate --spec-version is not appended.
|
||||
private async Task<(bool Success, string Output, string Error)> RunStatelessConformanceTestAsync(string arguments)
|
||||
{
|
||||
// Match lines like "Passed: 3/3, 0 failed, 0 warnings"
|
||||
var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed");
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int passed = int.Parse(match.Groups[1].Value);
|
||||
int total = int.Parse(match.Groups[2].Value);
|
||||
int failed = int.Parse(match.Groups[3].Value);
|
||||
|
||||
return passed == total && failed == 0 && total > 0;
|
||||
return await NodeHelpers.RunServerConformanceAsync(
|
||||
arguments,
|
||||
line => { try { output.WriteLine(line); } catch { } },
|
||||
appendProtocolVersionFromEnv: false,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,25 @@ public class Program
|
|||
// because .NET does not have a built-in concurrent HashSet
|
||||
ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> subscriptions = new();
|
||||
|
||||
// Allow running the server in the SEP-2575 stateless lifecycle, which the draft
|
||||
// "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false"
|
||||
// command-line switch (read via configuration) takes precedence so an in-process test
|
||||
// fixture can opt in or out per-instance deterministically; when it is not supplied,
|
||||
// fall back to the MCP_CONFORMANCE_STATELESS environment variable for standalone runs.
|
||||
// The default (no switch, no env var) remains the stateful server that serves the
|
||||
// active conformance suite unchanged.
|
||||
var statelessConfig = builder.Configuration["stateless"];
|
||||
var stateless = statelessConfig is not null
|
||||
? string.Equals(statelessConfig, "true", StringComparison.OrdinalIgnoreCase)
|
||||
: string.Equals(
|
||||
Environment.GetEnvironmentVariable("MCP_CONFORMANCE_STATELESS"),
|
||||
"true",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithHttpTransport(options => options.Stateless = stateless)
|
||||
.WithDistributedCacheEventStreamStore()
|
||||
.WithTools<ConformanceTools>()
|
||||
.WithTools<IncompleteResultTools>()
|
||||
|
|
@ -45,6 +60,44 @@ public class Program
|
|||
await request.EnablePollingAsync(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
// SEP-2549: advertise TTL/cacheScope caching hints on cacheable results. The
|
||||
// conformance server's tools, prompts, resources, and resource templates are the
|
||||
// same for every caller, so they are cacheable with a "public" scope.
|
||||
.AddListToolsFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await next(request, cancellationToken);
|
||||
result.TimeToLive = TimeSpan.FromMinutes(5);
|
||||
result.CacheScope = CacheScope.Public;
|
||||
return result;
|
||||
})
|
||||
.AddListPromptsFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await next(request, cancellationToken);
|
||||
result.TimeToLive = TimeSpan.FromMinutes(5);
|
||||
result.CacheScope = CacheScope.Public;
|
||||
return result;
|
||||
})
|
||||
.AddListResourcesFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await next(request, cancellationToken);
|
||||
result.TimeToLive = TimeSpan.FromMinutes(5);
|
||||
result.CacheScope = CacheScope.Public;
|
||||
return result;
|
||||
})
|
||||
.AddListResourceTemplatesFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await next(request, cancellationToken);
|
||||
result.TimeToLive = TimeSpan.FromMinutes(5);
|
||||
result.CacheScope = CacheScope.Public;
|
||||
return result;
|
||||
})
|
||||
.AddReadResourceFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await next(request, cancellationToken);
|
||||
result.TimeToLive = TimeSpan.FromMinutes(1);
|
||||
result.CacheScope = CacheScope.Public;
|
||||
return result;
|
||||
}))
|
||||
.WithPrompts<ConformancePrompts>()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests verifying that SEP-2549 caching hints set by a server on cacheable results
|
||||
/// are observed by a connected client.
|
||||
/// </summary>
|
||||
public class CacheableResultClientServerTests(ITestOutputHelper testOutputHelper)
|
||||
: ClientServerTestBase(testOutputHelper)
|
||||
{
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder
|
||||
.WithListToolsHandler((_, _) => new ValueTask<ListToolsResult>(new ListToolsResult
|
||||
{
|
||||
Tools = [new Tool { Name = "echo" }],
|
||||
TimeToLive = TimeSpan.FromMinutes(5),
|
||||
CacheScope = CacheScope.Public,
|
||||
}))
|
||||
.WithListPromptsHandler((_, _) => new ValueTask<ListPromptsResult>(new ListPromptsResult
|
||||
{
|
||||
Prompts = [new Prompt { Name = "greet" }],
|
||||
}))
|
||||
.WithListResourcesHandler((_, _) => new ValueTask<ListResourcesResult>(new ListResourcesResult
|
||||
{
|
||||
Resources = [new Resource { Uri = "test://resource", Name = "resource" }],
|
||||
}))
|
||||
.WithReadResourceHandler((request, _) => new ValueTask<ReadResourceResult>(new ReadResourceResult
|
||||
{
|
||||
Contents = [new TextResourceContents { Uri = request.Params!.Uri!, Text = "hi" }],
|
||||
TimeToLive = TimeSpan.FromSeconds(30),
|
||||
CacheScope = CacheScope.Private,
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTools_PropagatesCachingHints_ToClient()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ListToolsAsync(
|
||||
new ListToolsRequestParams(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Public, result.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResource_PropagatesCachingHints_ToClient()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ReadResourceAsync(
|
||||
"test://resource",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, result.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPrompts_WhenHandlerOmitsHints_ServerInjectsConservativeDefaults()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ListPromptsAsync(
|
||||
new ListPromptsRequestParams(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// SEP-2549: the handler left the hints unset, so the server fills in conservative defaults
|
||||
// (immediately stale, not shareable) rather than omitting the now-required fields.
|
||||
Assert.Equal(TimeSpan.Zero, result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, result.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListResources_WhenHandlerOmitsHints_ServerInjectsConservativeDefaults()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.ListResourcesAsync(
|
||||
new ListResourcesRequestParams(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(TimeSpan.Zero, result.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, result.CacheScope);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the SEP-2549 caching hints (<c>ttlMs</c> and <c>cacheScope</c>) carried by
|
||||
/// <see cref="ICacheableResult"/> implementations: 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>.
|
||||
/// </summary>
|
||||
public static class CacheableResultTests
|
||||
{
|
||||
public static IEnumerable<object[]> 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<long>());
|
||||
Assert.Equal("public", node["cacheScope"]!.GetValue<string>());
|
||||
|
||||
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<long>());
|
||||
Assert.Equal("private", node["cacheScope"]!.GetValue<string>());
|
||||
|
||||
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<CacheScope>("\"public\"", McpJsonUtilities.DefaultOptions));
|
||||
Assert.Equal(CacheScope.Private, JsonSerializer.Deserialize<CacheScope>("\"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<ListToolsResult>(
|
||||
JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)!;
|
||||
}
|
||||
Loading…
Reference in a new issue