Skip to content

Commit 8e7f28c

Browse files
tarekghTarek Mahmoud Sayed
andauthored
Document caching behavior and warn on non-conformant draft cacheable results (SEP-2549 follow-up) (modelcontextprotocol#1669)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
1 parent d6b1615 commit 8e7f28c

4 files changed

Lines changed: 428 additions & 10 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,18 @@ public ValueTask<PingResult> PingAsync(
173173
/// <returns>A list of all available tools as <see cref="McpClientTool"/> instances.</returns>
174174
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
175175
/// <remarks>
176+
/// <para>
176177
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
177178
/// (<see cref="ListToolsResult.TimeToLive"/> and <see cref="ListToolsResult.CacheScope"/>). To read those hints,
178179
/// use the <see cref="ListToolsAsync(ListToolsRequestParams, CancellationToken)"/> overload, which returns the
179180
/// raw <see cref="ListToolsResult"/> for each page.
181+
/// </para>
182+
/// <para>
183+
/// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
184+
/// If you want to cache listing results, do so in your own code using the lower-level
185+
/// <see cref="ListToolsAsync(ListToolsRequestParams, CancellationToken)"/> overload, which exposes the per-page
186+
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
187+
/// </para>
180188
/// </remarks>
181189
public async ValueTask<IList<McpClientTool>> ListToolsAsync(
182190
RequestOptions? options = null,
@@ -247,12 +255,25 @@ public ValueTask<ListToolsResult> ListToolsAsync(
247255
{
248256
Throw.IfNull(requestParams);
249257

250-
return SendRequestAsync(
258+
return ValidateCacheableResultAsync(RequestMethods.ToolsList, SendRequestAsync(
251259
RequestMethods.ToolsList,
252260
requestParams,
253261
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
254262
McpJsonUtilities.JsonContext.Default.ListToolsResult,
255-
cancellationToken: cancellationToken);
263+
cancellationToken: cancellationToken));
264+
}
265+
266+
/// <summary>
267+
/// Awaits a cacheable result and gives derived clients a chance to emit diagnostics (for example, a
268+
/// SEP-2549 conformance warning) before returning it. Preserves the synchronous argument validation
269+
/// performed by the callers before the request is issued.
270+
/// </summary>
271+
private async ValueTask<TResult> ValidateCacheableResultAsync<TResult>(string method, ValueTask<TResult> resultTask)
272+
where TResult : ICacheableResult
273+
{
274+
var result = await resultTask.ConfigureAwait(false);
275+
ValidateCacheableResult(method, result);
276+
return result;
256277
}
257278

258279
/// <summary>
@@ -263,10 +284,18 @@ public ValueTask<ListToolsResult> ListToolsAsync(
263284
/// <returns>A list of all available prompts as <see cref="McpClientPrompt"/> instances.</returns>
264285
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
265286
/// <remarks>
287+
/// <para>
266288
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
267289
/// (<see cref="ListPromptsResult.TimeToLive"/> and <see cref="ListPromptsResult.CacheScope"/>). To read those hints,
268290
/// use the <see cref="ListPromptsAsync(ListPromptsRequestParams, CancellationToken)"/> overload, which returns the
269291
/// raw <see cref="ListPromptsResult"/> for each page.
292+
/// </para>
293+
/// <para>
294+
/// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
295+
/// If you want to cache listing results, do so in your own code using the lower-level
296+
/// <see cref="ListPromptsAsync(ListPromptsRequestParams, CancellationToken)"/> overload, which exposes the per-page
297+
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
298+
/// </para>
270299
/// </remarks>
271300
public async ValueTask<IList<McpClientPrompt>> ListPromptsAsync(
272301
RequestOptions? options = null,
@@ -309,12 +338,12 @@ public ValueTask<ListPromptsResult> ListPromptsAsync(
309338
{
310339
Throw.IfNull(requestParams);
311340

312-
return SendRequestAsync(
341+
return ValidateCacheableResultAsync(RequestMethods.PromptsList, SendRequestAsync(
313342
RequestMethods.PromptsList,
314343
requestParams,
315344
McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams,
316345
McpJsonUtilities.JsonContext.Default.ListPromptsResult,
317-
cancellationToken: cancellationToken);
346+
cancellationToken: cancellationToken));
318347
}
319348

320349
/// <summary>
@@ -379,10 +408,18 @@ public ValueTask<GetPromptResult> GetPromptAsync(
379408
/// <returns>A list of all available resource templates as <see cref="ResourceTemplate"/> instances.</returns>
380409
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
381410
/// <remarks>
411+
/// <para>
382412
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
383413
/// (<see cref="ListResourceTemplatesResult.TimeToLive"/> and <see cref="ListResourceTemplatesResult.CacheScope"/>). To read those hints,
384414
/// use the <see cref="ListResourceTemplatesAsync(ListResourceTemplatesRequestParams, CancellationToken)"/> overload, which returns the
385415
/// raw <see cref="ListResourceTemplatesResult"/> for each page.
416+
/// </para>
417+
/// <para>
418+
/// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
419+
/// If you want to cache listing results, do so in your own code using the lower-level
420+
/// <see cref="ListResourceTemplatesAsync(ListResourceTemplatesRequestParams, CancellationToken)"/> overload, which exposes the per-page
421+
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
422+
/// </para>
386423
/// </remarks>
387424
public async ValueTask<IList<McpClientResourceTemplate>> ListResourceTemplatesAsync(
388425
RequestOptions? options = null,
@@ -425,12 +462,12 @@ public ValueTask<ListResourceTemplatesResult> ListResourceTemplatesAsync(
425462
{
426463
Throw.IfNull(requestParams);
427464

428-
return SendRequestAsync(
465+
return ValidateCacheableResultAsync(RequestMethods.ResourcesTemplatesList, SendRequestAsync(
429466
RequestMethods.ResourcesTemplatesList,
430467
requestParams,
431468
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams,
432469
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
433-
cancellationToken: cancellationToken);
470+
cancellationToken: cancellationToken));
434471
}
435472

436473
/// <summary>
@@ -441,10 +478,18 @@ public ValueTask<ListResourceTemplatesResult> ListResourceTemplatesAsync(
441478
/// <returns>A list of all available resources as <see cref="Resource"/> instances.</returns>
442479
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
443480
/// <remarks>
481+
/// <para>
444482
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
445483
/// (<see cref="ListResourcesResult.TimeToLive"/> and <see cref="ListResourcesResult.CacheScope"/>). To read those hints,
446484
/// use the <see cref="ListResourcesAsync(ListResourcesRequestParams, CancellationToken)"/> overload, which returns the
447485
/// raw <see cref="ListResourcesResult"/> for each page.
486+
/// </para>
487+
/// <para>
488+
/// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server.
489+
/// If you want to cache listing results, do so in your own code using the lower-level
490+
/// <see cref="ListResourcesAsync(ListResourcesRequestParams, CancellationToken)"/> overload, which exposes the per-page
491+
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
492+
/// </para>
448493
/// </remarks>
449494
public async ValueTask<IList<McpClientResource>> ListResourcesAsync(
450495
RequestOptions? options = null,
@@ -487,12 +532,12 @@ public ValueTask<ListResourcesResult> ListResourcesAsync(
487532
{
488533
Throw.IfNull(requestParams);
489534

490-
return SendRequestAsync(
535+
return ValidateCacheableResultAsync(RequestMethods.ResourcesList, SendRequestAsync(
491536
RequestMethods.ResourcesList,
492537
requestParams,
493538
McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams,
494539
McpJsonUtilities.JsonContext.Default.ListResourcesResult,
495-
cancellationToken: cancellationToken);
540+
cancellationToken: cancellationToken));
496541
}
497542

498543
/// <summary>
@@ -571,12 +616,12 @@ public ValueTask<ReadResourceResult> ReadResourceAsync(
571616
{
572617
Throw.IfNull(requestParams);
573618

574-
return SendRequestAsync(
619+
return ValidateCacheableResultAsync(RequestMethods.ResourcesRead, SendRequestAsync(
575620
RequestMethods.ResourcesRead,
576621
requestParams,
577622
McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams,
578623
McpJsonUtilities.JsonContext.Default.ReadResourceResult,
579-
cancellationToken: cancellationToken);
624+
cancellationToken: cancellationToken));
580625
}
581626

582627
/// <summary>

src/ModelContextProtocol.Core/Client/McpClient.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ private protected abstract ValueTask<IDictionary<string, InputResponse>> Resolve
9292
/// </summary>
9393
private protected abstract int MaxConsecutiveStuckPolls { get; }
9494

95+
/// <summary>
96+
/// Inspects a received cacheable result (<c>tools/list</c>, <c>prompts/list</c>, <c>resources/list</c>,
97+
/// <c>resources/templates/list</c>, or <c>resources/read</c>) so derived clients can emit diagnostics.
98+
/// </summary>
99+
/// <param name="method">The request method that produced the result.</param>
100+
/// <param name="result">The cacheable result returned by the server.</param>
101+
/// <remarks>
102+
/// This is used to warn (never throw) when a server that negotiated a protocol version requiring the
103+
/// SEP-2549 <c>ttlMs</c>/<c>cacheScope</c> fields omits them. The default implementation does nothing.
104+
/// </remarks>
105+
private protected virtual void ValidateCacheableResult(string method, ICacheableResult result)
106+
{
107+
}
108+
95109
/// <summary>
96110
/// Registers one or more tool definitions in the client's tool cache, enabling the transport
97111
/// to send <c>Mcp-Param-*</c> headers for those tools without requiring a prior <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> call.

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ internal sealed partial class McpClientImpl : McpClient
2525
private readonly SemaphoreSlim _disposeLock = new(1, 1);
2626
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
2727
private readonly ConcurrentDictionary<string, byte> _registeredToolNames = new(StringComparer.Ordinal);
28+
private readonly ConcurrentDictionary<string, byte> _cacheableConformanceWarnedMethods = new(StringComparer.Ordinal);
2829

2930
private ServerCapabilities? _serverCapabilities;
3031
private Implementation? _serverInfo;
@@ -763,6 +764,34 @@ private void WarnIfInputRequiredResultOnNonMrtrSession(string method)
763764
}
764765
}
765766

767+
/// <summary>
768+
/// Logs a warning (never throws) when a server that negotiated the 2026-07-28 (or later) protocol version
769+
/// omits the SEP-2549 <c>ttlMs</c>/<c>cacheScope</c> fields, which are required on cacheable results for
770+
/// those versions. The warning is emitted at most once per method per session so that paginated listings do
771+
/// not produce one warning per page.
772+
/// </summary>
773+
private protected override void ValidateCacheableResult(string method, ICacheableResult result)
774+
{
775+
if (!IsJuly2026OrLaterProtocol())
776+
{
777+
return;
778+
}
779+
780+
bool missingTtl = result.TimeToLive is null;
781+
bool missingScope = result.CacheScope is null;
782+
if ((missingTtl || missingScope) && _cacheableConformanceWarnedMethods.TryAdd(method, 0))
783+
{
784+
string missingFields =
785+
missingTtl && missingScope ? "ttlMs, cacheScope" :
786+
missingTtl ? "ttlMs" :
787+
"cacheScope";
788+
LogCacheableResultMissingRequiredFields(_endpointName, method, missingFields, _negotiatedProtocolVersion);
789+
}
790+
}
791+
792+
[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.")]
793+
private partial void LogCacheableResultMissingRequiredFields(string endpointName, string method, string missingFields, string? protocolVersion);
794+
766795
[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.")]
767796
private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method);
768797

0 commit comments

Comments
 (0)