Document caching behavior and warn on non-conformant draft cacheable results (SEP-2549 follow-up) (#1669)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
d6b1615988
commit
8e7f28c237
3 changed files with 428 additions and 10 deletions
|
|
@ -173,10 +173,18 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="ListToolsAsync(ListToolsRequestParams, CancellationToken)"/> overload, which exposes the per-page
|
||||
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<IList<McpClientTool>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async ValueTask<TResult> ValidateCacheableResultAsync<TResult>(string method, ValueTask<TResult> resultTask)
|
||||
where TResult : ICacheableResult
|
||||
{
|
||||
var result = await resultTask.ConfigureAwait(false);
|
||||
ValidateCacheableResult(method, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -263,10 +284,18 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="ListPromptsAsync(ListPromptsRequestParams, CancellationToken)"/> overload, which exposes the per-page
|
||||
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<IList<McpClientPrompt>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -379,10 +408,18 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="ListResourceTemplatesAsync(ListResourceTemplatesRequestParams, CancellationToken)"/> overload, which exposes the per-page
|
||||
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<IList<McpClientResourceTemplate>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -441,10 +478,18 @@ public abstract partial class McpClient : McpSession
|
|||
/// <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>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="ListResourcesAsync(ListResourcesRequestParams, CancellationToken)"/> overload, which exposes the per-page
|
||||
/// caching hints and lets you manage pagination so each page can be cached and expired independently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<IList<McpClientResource>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -92,6 +92,20 @@ public abstract partial class McpClient : McpSession
|
|||
/// </summary>
|
||||
private protected abstract int MaxConsecutiveStuckPolls { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Inspects a received cacheable result (<c>tools/list</c>, <c>prompts/list</c>, <c>resources/list</c>,
|
||||
/// <c>resources/templates/list</c>, or <c>resources/read</c>) so derived clients can emit diagnostics.
|
||||
/// </summary>
|
||||
/// <param name="method">The request method that produced the result.</param>
|
||||
/// <param name="result">The cacheable result returned by the server.</param>
|
||||
/// <remarks>
|
||||
/// This is used to warn (never throw) when a server that negotiated a protocol version requiring the
|
||||
/// SEP-2549 <c>ttlMs</c>/<c>cacheScope</c> fields omits them. The default implementation does nothing.
|
||||
/// </remarks>
|
||||
private protected virtual void ValidateCacheableResult(string method, ICacheableResult result)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers one or more tool definitions in the client's tool cache, enabling the transport
|
||||
/// to send <c>Mcp-Param-*</c> headers for those tools without requiring a prior <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> call.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, byte> _registeredToolNames = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, byte> _cacheableConformanceWarnedMethods = new(StringComparer.Ordinal);
|
||||
|
||||
private ServerCapabilities? _serverCapabilities;
|
||||
private Implementation? _serverInfo;
|
||||
|
|
@ -763,6 +764,34 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs a warning (never throws) when a server that negotiated the 2026-07-28 (or later) protocol version
|
||||
/// omits the SEP-2549 <c>ttlMs</c>/<c>cacheScope</c> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue