diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index 70eb30d0d..7ee7644c7 100644
--- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
@@ -140,6 +140,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSerializable(typeof(PingResult))]
[JsonSerializable(typeof(ReadResourceRequestParams))]
[JsonSerializable(typeof(ReadResourceResult))]
+ [JsonSerializable(typeof(CacheScope))]
[JsonSerializable(typeof(SetLevelRequestParams))]
[JsonSerializable(typeof(SubscribeRequestParams))]
[JsonSerializable(typeof(UnsubscribeRequestParams))]
diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScope.cs b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs
new file mode 100644
index 000000000..d87cdd7f9
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs
@@ -0,0 +1,44 @@
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Protocol;
+
+///
+/// Indicates the intended scope of a cached response, analogous to the HTTP
+/// Cache-Control: public and Cache-Control: private directives.
+///
+///
+///
+/// This is used by to control who may cache a
+/// response returned by tools/list, prompts/list, resources/list,
+/// resources/templates/list, and resources/read.
+///
+///
+/// When the field is absent from a response, clients should treat it as .
+///
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum CacheScope
+{
+ ///
+ /// 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.
+ ///
+ ///
+ /// This is appropriate for lists of tools, prompts, and resource templates that are identical
+ /// for all users.
+ ///
+ [JsonStringEnumMemberName("public")]
+ Public,
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// This is appropriate for resources/read results that depend on the authenticated user,
+ /// or for filtered list results that vary per user.
+ ///
+ [JsonStringEnumMemberName("private")]
+ Private
+}
diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs
new file mode 100644
index 000000000..ef61df263
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs
@@ -0,0 +1,70 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Protocol;
+
+///
+/// Serializes caching-scope hints, tolerating unknown or future values on read.
+///
+///
+///
+/// SEP-2549 introduces cacheScope 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 rather than throwing. This prevents
+/// a single unexpected hint from breaking deserialization of the entire result (for example, the whole
+/// tool list). A result is the same as an absent field, which clients treat as
+/// .
+///
+///
+/// This converter is applied per-property on the cacheable result types. The
+/// enum itself retains a standard string converter for any standalone serialization.
+///
+///
+internal sealed class CacheScopeConverter : JsonConverter
+{
+ 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}."),
+ });
+ }
+}
diff --git a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs
new file mode 100644
index 000000000..33169208a
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs
@@ -0,0 +1,58 @@
+namespace ModelContextProtocol.Protocol;
+
+///
+/// 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.
+///
+///
+///
+/// This interface corresponds to the CacheableResult type in the Model Context Protocol
+/// schema and is implemented by the results of tools/list, prompts/list,
+/// resources/list, resources/templates/list, and resources/read.
+///
+///
+/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing
+/// list_changed and resources/updated notification mechanisms; both can coexist. A
+/// relevant notification invalidates a cached response regardless of any remaining TTL.
+///
+///
+public interface ICacheableResult
+{
+ ///
+ /// Gets or sets a hint indicating how long the client may cache this response before re-fetching.
+ ///
+ ///
+ ///
+ /// The semantics are analogous to the HTTP Cache-Control: max-age directive. The value is
+ /// serialized as an integer number of milliseconds under the ttlMs JSON property.
+ ///
+ ///
+ /// A value of 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.
+ ///
+ ///
+ /// When this property is (the field was absent from the response), clients
+ /// should assume a default of (immediately stale) and rely on their
+ /// own caching heuristics or notifications. A negative value should likewise be treated as
+ /// .
+ ///
+ ///
+ TimeSpan? TimeToLive { get; set; }
+
+ ///
+ /// Gets or sets the intended scope of the cached response.
+ ///
+ ///
+ ///
+ /// When this property is (the field was absent from the response), clients
+ /// should treat the response as .
+ ///
+ ///
+ /// An unrecognized or future scope value sent by a server (or a non-string value) is tolerated and
+ /// surfaced as rather than causing deserialization of the whole result to
+ /// fail, so a single unexpected hint never prevents a client from reading the result.
+ ///
+ ///
+ CacheScope? CacheScope { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs
index 1f648bd5a..a7f26b521 100644
--- a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs
@@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
/// See the schema for details.
///
///
-public sealed class ListPromptsResult : PaginatedResult
+public sealed class ListPromptsResult : PaginatedResult, ICacheableResult
{
///
/// Gets or sets a list of prompts or prompt templates that the server offers.
///
[JsonPropertyName("prompts")]
public IList Prompts { get; set; } = [];
+
+ ///
+ [JsonPropertyName("ttlMs")]
+ [JsonConverter(typeof(TimeSpanMillisecondsConverter))]
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ [JsonPropertyName("cacheScope")]
+ [JsonConverter(typeof(CacheScopeConverter))]
+ public CacheScope? CacheScope { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs
index 6e422a751..988d6f186 100644
--- a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs
@@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol;
/// See the schema for details.
///
///
-public sealed class ListResourceTemplatesResult : PaginatedResult
+public sealed class ListResourceTemplatesResult : PaginatedResult, ICacheableResult
{
///
/// Gets or sets a list of resource templates that the server offers.
@@ -32,4 +32,14 @@ public sealed class ListResourceTemplatesResult : PaginatedResult
///
[JsonPropertyName("resourceTemplates")]
public IList ResourceTemplates { get; set; } = [];
+
+ ///
+ [JsonPropertyName("ttlMs")]
+ [JsonConverter(typeof(TimeSpanMillisecondsConverter))]
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ [JsonPropertyName("cacheScope")]
+ [JsonConverter(typeof(CacheScopeConverter))]
+ public CacheScope? CacheScope { get; set; }
}
\ No newline at end of file
diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs
index 16d01491c..54c1df601 100644
--- a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs
@@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
/// See the schema for details.
///
///
-public sealed class ListResourcesResult : PaginatedResult
+public sealed class ListResourcesResult : PaginatedResult, ICacheableResult
{
///
/// Gets or sets a list of resources that the server offers.
///
[JsonPropertyName("resources")]
public IList Resources { get; set; } = [];
+
+ ///
+ [JsonPropertyName("ttlMs")]
+ [JsonConverter(typeof(TimeSpanMillisecondsConverter))]
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ [JsonPropertyName("cacheScope")]
+ [JsonConverter(typeof(CacheScopeConverter))]
+ public CacheScope? CacheScope { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs
index a2f03b853..55eed5ddb 100644
--- a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs
@@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol;
/// See the schema for details.
///
///
-public sealed class ListToolsResult : PaginatedResult
+public sealed class ListToolsResult : PaginatedResult, ICacheableResult
{
///
/// Gets or sets the server's response to a tools/list request from the client.
///
[JsonPropertyName("tools")]
public IList Tools { get; set; } = [];
+
+ ///
+ [JsonPropertyName("ttlMs")]
+ [JsonConverter(typeof(TimeSpanMillisecondsConverter))]
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ [JsonPropertyName("cacheScope")]
+ [JsonConverter(typeof(CacheScopeConverter))]
+ public CacheScope? CacheScope { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs
index 084322fde..53e138806 100644
--- a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs
@@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol;
///
/// See the schema for details.
///
-public sealed class ReadResourceResult : Result
+public sealed class ReadResourceResult : Result, ICacheableResult
{
///
/// Gets or sets a list of objects that this resource contains.
@@ -20,4 +20,14 @@ public sealed class ReadResourceResult : Result
///
[JsonPropertyName("contents")]
public IList Contents { get; set; } = [];
+
+ ///
+ [JsonPropertyName("ttlMs")]
+ [JsonConverter(typeof(TimeSpanMillisecondsConverter))]
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ [JsonPropertyName("cacheScope")]
+ [JsonConverter(typeof(CacheScopeConverter))]
+ public CacheScope? CacheScope { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs
index e789db186..18386d326 100644
--- a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs
+++ b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs
@@ -10,7 +10,10 @@ namespace ModelContextProtocol.Protocol;
///
/// 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.
+/// handles nullable TimeSpan properties using this converter. Millisecond values that fall outside
+/// the range representable by are clamped to
+/// / rather than throwing, so an
+/// oversized or malformed hint can never break deserialization of the enclosing result.
///
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class TimeSpanMillisecondsConverter : JsonConverter
@@ -22,17 +25,93 @@ public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, Jso
{
if (reader.TryGetInt64(out long milliseconds))
{
- return TimeSpan.FromMilliseconds(milliseconds);
+ return FromMillisecondsClamped(milliseconds);
}
- // For non-integer values, convert from fractional milliseconds
- double fractionalMilliseconds = reader.GetDouble();
- return TimeSpan.FromTicks((long)(fractionalMilliseconds * TimeSpan.TicksPerMillisecond));
+ // 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 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);
+ }
+
///
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
{
diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs
index a30dd3fc3..b3b10bab6 100644
--- a/tests/Common/Utils/NodeHelpers.cs
+++ b/tests/Common/Utils/NodeHelpers.cs
@@ -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 void EnsureNpmDependenciesInstalled()
///
/// The name of the binary in node_modules/.bin (e.g. "conformance").
/// The arguments to pass to the binary.
+ ///
+ /// When (the default) and the MCP_CONFORMANCE_PROTOCOL_VERSION
+ /// environment variable is set, a "--spec-version <value>" argument is appended.
+ /// Pass for scenarios that pin their own spec version (e.g. the
+ /// draft-only caching scenario) to avoid a conflicting duplicate flag.
+ ///
/// A configured ProcessStartInfo for running the binary.
- 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 bool IsNodeInstalled()
}
///
- /// 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
+ /// installed 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
+ /// when a newer private build has been installed locally via
+ /// npm install --no-save <path-to-conformance>.
+ ///
+ public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
+
+ ///
+ /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance
+ /// PR #275) is available, by reading the installed 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 when a newer private build has been installed
+ /// locally via npm install --no-save <path-to-conformance>.
+ ///
+ public static bool HasCachingScenario() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
+
+ ///
+ /// Returns when the conformance package installed in node_modules
+ /// has a version greater than or equal to .
///
- public static bool HasSep2243Scenarios()
+ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion)
+ {
+ var version = GetInstalledConformanceVersion();
+ return version is not null && version >= minimumVersion;
+ }
+
+ ///
+ /// 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 . Returns if it cannot be
+ /// determined.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ /// Arguments to pass to the conformance runner.
+ /// Optional callback invoked for each captured stdout/stderr line.
+ ///
+ /// Forwarded to .
+ ///
+ /// Token used to cancel the run.
+ public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync(
+ string arguments,
+ Action? 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
}
private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs
new file mode 100644
index 000000000..5cdd2948a
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs
@@ -0,0 +1,139 @@
+using System.Diagnostics;
+using ModelContextProtocol.Tests.Utils;
+
+namespace ModelContextProtocol.ConformanceTests;
+
+///
+/// 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 . Uses a
+/// distinct port range from the stateful ConformanceServerFixture (3001/3002/3003) so
+/// the two can run in parallel without TCP conflicts.
+///
+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 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();
+ }
+}
+
+///
+/// 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 ttlMs and cacheScope caching hints on cacheable results
+/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read).
+///
+///
+/// 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 npm install --no-save <path-to-conformance>). The stateless
+/// server is started only after the gates pass, so a skipped run binds no port.
+///
+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}");
+ }
+}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
index 7b2be118b..f389ffbba 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
@@ -63,7 +63,7 @@ public async Task RunConformanceTest(string scenario)
}
// 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")]
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index 98cc5971a..83481a76c 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -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 @@ private static int GetPortForTargetFramework()
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 async Task RunPendingConformanceTest_ServerSsePolling()
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).");
+
+ // 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 RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation");
+ 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 async Task RunConformanceTest_HttpHeaderValidation()
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).");
+
+ await using var server = await StatelessConformanceServer.StartAsync(
+ TestContext.Current.CancellationToken, basePort: 3024);
- var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation");
+ 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}");
@@ -161,94 +177,20 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation()
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);
}
- ///
- /// 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.
- ///
- 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);
}
}
diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs
index 017ec235f..4c8c97887 100644
--- a/tests/ModelContextProtocol.ConformanceServer/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs
@@ -25,10 +25,25 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
// because .NET does not have a built-in concurrent HashSet
ConcurrentDictionary> 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()
.WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
@@ -44,6 +59,44 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
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()
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs
new file mode 100644
index 000000000..38e690d07
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+///
+/// End-to-end tests verifying that SEP-2549 caching hints set by a server on cacheable results
+/// are observed by a connected client.
+///
+public class CacheableResultClientServerTests(ITestOutputHelper testOutputHelper)
+ : ClientServerTestBase(testOutputHelper)
+{
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ mcpServerBuilder
+ .WithListToolsHandler((_, _) => new ValueTask(new ListToolsResult
+ {
+ Tools = [new Tool { Name = "echo" }],
+ TimeToLive = TimeSpan.FromMinutes(5),
+ CacheScope = CacheScope.Public,
+ }))
+ .WithReadResourceHandler((request, _) => new ValueTask(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);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs
new file mode 100644
index 000000000..aba38bfa0
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs
@@ -0,0 +1,292 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+///
+/// Tests for the SEP-2549 caching hints (ttlMs and cacheScope) carried by
+/// implementations: the results of tools/list,
+/// prompts/list, resources/list, resources/templates/list, and
+/// resources/read.
+///
+public static class CacheableResultTests
+{
+ public static IEnumerable