diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index bfe81d1..9ecfe1c 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -173,10 +173,18 @@ public abstract partial class McpClient : McpSession
/// A list of all available tools as instances.
/// The request failed or the server returned an error response.
///
+ ///
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// ( and ). To read those hints,
/// use the overload, which returns the
/// raw for each page.
+ ///
+ ///
+ /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
+ /// If you want to cache listing results, do so in your own code using the lower-level
+ /// overload, which exposes the per-page
+ /// caching hints and lets you manage pagination so each page can be cached and expired independently.
+ ///
///
public async ValueTask> ListToolsAsync(
RequestOptions? options = null,
@@ -247,12 +255,25 @@ public abstract partial class McpClient : McpSession
{
Throw.IfNull(requestParams);
- return SendRequestAsync(
+ return ValidateCacheableResultAsync(RequestMethods.ToolsList, SendRequestAsync(
RequestMethods.ToolsList,
requestParams,
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
McpJsonUtilities.JsonContext.Default.ListToolsResult,
- cancellationToken: cancellationToken);
+ cancellationToken: cancellationToken));
+ }
+
+ ///
+ /// Awaits a cacheable result and gives derived clients a chance to emit diagnostics (for example, a
+ /// SEP-2549 conformance warning) before returning it. Preserves the synchronous argument validation
+ /// performed by the callers before the request is issued.
+ ///
+ private async ValueTask ValidateCacheableResultAsync(string method, ValueTask resultTask)
+ where TResult : ICacheableResult
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ ValidateCacheableResult(method, result);
+ return result;
}
///
@@ -263,10 +284,18 @@ public abstract partial class McpClient : McpSession
/// A list of all available prompts as instances.
/// The request failed or the server returned an error response.
///
+ ///
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// ( and ). To read those hints,
/// use the overload, which returns the
/// raw for each page.
+ ///
+ ///
+ /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
+ /// If you want to cache listing results, do so in your own code using the lower-level
+ /// overload, which exposes the per-page
+ /// caching hints and lets you manage pagination so each page can be cached and expired independently.
+ ///
///
public async ValueTask> ListPromptsAsync(
RequestOptions? options = null,
@@ -309,12 +338,12 @@ public abstract partial class McpClient : McpSession
{
Throw.IfNull(requestParams);
- return SendRequestAsync(
+ return ValidateCacheableResultAsync(RequestMethods.PromptsList, SendRequestAsync(
RequestMethods.PromptsList,
requestParams,
McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams,
McpJsonUtilities.JsonContext.Default.ListPromptsResult,
- cancellationToken: cancellationToken);
+ cancellationToken: cancellationToken));
}
///
@@ -379,10 +408,18 @@ public abstract partial class McpClient : McpSession
/// A list of all available resource templates as instances.
/// The request failed or the server returned an error response.
///
+ ///
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// ( and ). To read those hints,
/// use the overload, which returns the
/// raw for each page.
+ ///
+ ///
+ /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
+ /// If you want to cache listing results, do so in your own code using the lower-level
+ /// overload, which exposes the per-page
+ /// caching hints and lets you manage pagination so each page can be cached and expired independently.
+ ///
///
public async ValueTask> ListResourceTemplatesAsync(
RequestOptions? options = null,
@@ -425,12 +462,12 @@ public abstract partial class McpClient : McpSession
{
Throw.IfNull(requestParams);
- return SendRequestAsync(
+ return ValidateCacheableResultAsync(RequestMethods.ResourcesTemplatesList, SendRequestAsync(
RequestMethods.ResourcesTemplatesList,
requestParams,
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams,
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
- cancellationToken: cancellationToken);
+ cancellationToken: cancellationToken));
}
///
@@ -441,10 +478,18 @@ public abstract partial class McpClient : McpSession
/// A list of all available resources as instances.
/// The request failed or the server returned an error response.
///
+ ///
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
/// ( and ). To read those hints,
/// use the overload, which returns the
/// raw for each page.
+ ///
+ ///
+ /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
+ /// If you want to cache listing results, do so in your own code using the lower-level
+ /// overload, which exposes the per-page
+ /// caching hints and lets you manage pagination so each page can be cached and expired independently.
+ ///
///
public async ValueTask> ListResourcesAsync(
RequestOptions? options = null,
@@ -487,12 +532,12 @@ public abstract partial class McpClient : McpSession
{
Throw.IfNull(requestParams);
- return SendRequestAsync(
+ return ValidateCacheableResultAsync(RequestMethods.ResourcesList, SendRequestAsync(
RequestMethods.ResourcesList,
requestParams,
McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams,
McpJsonUtilities.JsonContext.Default.ListResourcesResult,
- cancellationToken: cancellationToken);
+ cancellationToken: cancellationToken));
}
///
@@ -571,12 +616,12 @@ public abstract partial class McpClient : McpSession
{
Throw.IfNull(requestParams);
- return SendRequestAsync(
+ return ValidateCacheableResultAsync(RequestMethods.ResourcesRead, SendRequestAsync(
RequestMethods.ResourcesRead,
requestParams,
McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams,
McpJsonUtilities.JsonContext.Default.ReadResourceResult,
- cancellationToken: cancellationToken);
+ cancellationToken: cancellationToken));
}
///
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs
index b238c59..da85e97 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.cs
@@ -92,6 +92,20 @@ public abstract partial class McpClient : McpSession
///
private protected abstract int MaxConsecutiveStuckPolls { get; }
+ ///
+ /// Inspects a received cacheable result (tools/list, prompts/list, resources/list,
+ /// resources/templates/list, or resources/read) so derived clients can emit diagnostics.
+ ///
+ /// The request method that produced the result.
+ /// The cacheable result returned by the server.
+ ///
+ /// This is used to warn (never throw) when a server that negotiated a protocol version requiring the
+ /// SEP-2549 ttlMs/cacheScope fields omits them. The default implementation does nothing.
+ ///
+ private protected virtual void ValidateCacheableResult(string method, ICacheableResult result)
+ {
+ }
+
///
/// Registers one or more tool definitions in the client's tool cache, enabling the transport
/// to send Mcp-Param-* headers for those tools without requiring a prior call.
diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
index eb2fedc..39f6579 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@@ -25,6 +25,7 @@ internal sealed partial class McpClientImpl : McpClient
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly ConcurrentDictionary _toolCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _registeredToolNames = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _cacheableConformanceWarnedMethods = new(StringComparer.Ordinal);
private ServerCapabilities? _serverCapabilities;
private Implementation? _serverInfo;
@@ -763,6 +764,34 @@ internal sealed partial class McpClientImpl : McpClient
}
}
+ ///
+ /// Logs a warning (never throws) when a server that negotiated the 2026-07-28 (or later) protocol version
+ /// omits the SEP-2549 ttlMs/cacheScope fields, which are required on cacheable results for
+ /// those versions. The warning is emitted at most once per method per session so that paginated listings do
+ /// not produce one warning per page.
+ ///
+ private protected override void ValidateCacheableResult(string method, ICacheableResult result)
+ {
+ if (!IsJuly2026OrLaterProtocol())
+ {
+ return;
+ }
+
+ bool missingTtl = result.TimeToLive is null;
+ bool missingScope = result.CacheScope is null;
+ if ((missingTtl || missingScope) && _cacheableConformanceWarnedMethods.TryAdd(method, 0))
+ {
+ string missingFields =
+ missingTtl && missingScope ? "ttlMs, cacheScope" :
+ missingTtl ? "ttlMs" :
+ "cacheScope";
+ LogCacheableResultMissingRequiredFields(_endpointName, method, missingFields, _negotiatedProtocolVersion);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received '{Method}' result missing required SEP-2549 field(s) '{MissingFields}' from a server that negotiated protocol version '{ProtocolVersion}'. The server may not be spec-compliant.")]
+ private partial void LogCacheableResultMissingRequiredFields(string endpointName, string method, string missingFields, string? protocolVersion);
+
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received legacy '{Method}' JSON-RPC request on session that negotiated MRTR. The server should use InputRequiredResult instead of sending direct requests.")]
private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method);
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs
new file mode 100644
index 0000000..14f0d49
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs
@@ -0,0 +1,330 @@
+// Excluded on .NET Framework: the in-memory server helper uses Stream/StreamReader async overloads
+// that take a CancellationToken (e.g. StreamReader.ReadLineAsync(CancellationToken) and
+// Stream.WriteAsync(ReadOnlyMemory, CancellationToken)) which are not available on net472.
+#if !NET472
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Tests.Utils;
+using System.IO.Pipelines;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+///
+/// Tests for the client-side SEP-2549 conformance warning: when a server that negotiated the 2026-07-28
+/// (or later) protocol version returns a cacheable result (tools/list, prompts/list, resources/list,
+/// resources/templates/list, resources/read) without the now-required ttlMs/cacheScope
+/// fields, the client logs a warning but never throws.
+///
+public class CacheableResultWarningTests : LoggedTest
+{
+ private const string July2026ProtocolVersion = "2026-07-28";
+ private const string OlderProtocolVersion = "2025-11-25";
+
+ public CacheableResultWarningTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ public static IEnumerable