Skip to content

Commit 14746ef

Browse files
author
Tarek Mahmoud Sayed
committed
Document caching behavior and warn on non-conformant draft cacheable results
Follow-up to the SEP-2549 caching hints work in PR #1623, addressing two of its tracked follow-up issues: Fixes #1645: Tighten the doc comments on the auto-paginating list overloads (tools/prompts/resources/resource-templates) to state plainly that the SDK performs no internal caching of listing results, and that any caching of ttlMs/cacheScope must be done by the caller via the lower-level non-auto-paginating overloads, which surface those fields per page. Fixes #1650: Log a warning (never throw) when a server that negotiated the draft 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 warning is emitted at most once per method per session so paginated listings do not produce one warning per page. The raw cacheable overloads keep their synchronous argument validation and route results through a shared ValidateCacheableResultAsync helper that calls the new ValidateCacheableResult seam on McpClient. The seam is a no-op by default and overridden by McpClientImpl to perform the conformance check. Issue #1646 (optional client-side caching subsystem) was reviewed and closed as not planned; no caching subsystem is added here.
1 parent 3368bb7 commit 14746ef

4 files changed

Lines changed: 415 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;
@@ -578,6 +579,34 @@ private void WarnIfInputRequiredResultOnNonMrtrSession(string method)
578579
}
579580
}
580581

582+
/// <summary>
583+
/// Logs a warning (never throws) when a server that negotiated the draft protocol version omits the
584+
/// SEP-2549 <c>ttlMs</c>/<c>cacheScope</c> fields, which are required on cacheable results for that version.
585+
/// The warning is emitted at most once per method per session so that paginated listings do not produce
586+
/// one warning per page.
587+
/// </summary>
588+
private protected override void ValidateCacheableResult(string method, ICacheableResult result)
589+
{
590+
if (_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion)
591+
{
592+
return;
593+
}
594+
595+
bool missingTtl = result.TimeToLive is null;
596+
bool missingScope = result.CacheScope is null;
597+
if ((missingTtl || missingScope) && _cacheableConformanceWarnedMethods.TryAdd(method, 0))
598+
{
599+
string missingFields =
600+
missingTtl && missingScope ? "ttlMs, cacheScope" :
601+
missingTtl ? "ttlMs" :
602+
"cacheScope";
603+
LogCacheableResultMissingRequiredFields(_endpointName, method, missingFields, _negotiatedProtocolVersion);
604+
}
605+
}
606+
607+
[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.")]
608+
private partial void LogCacheableResultMissingRequiredFields(string endpointName, string method, string missingFields, string? protocolVersion);
609+
581610
[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.")]
582611
private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method);
583612

0 commit comments

Comments
 (0)