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
4 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>
|
/// <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>
|
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
|
/// 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,
|
/// (<see cref="ListToolsResult.TimeToLive"/> and <see cref="ListToolsResult.CacheScope"/>). To read those hints,
|
||||||
/// use the <see cref="ListToolsAsync(ListToolsRequestParams, CancellationToken)"/> overload, which returns the
|
/// use the <see cref="ListToolsAsync(ListToolsRequestParams, CancellationToken)"/> overload, which returns the
|
||||||
/// raw <see cref="ListToolsResult"/> for each page.
|
/// 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>
|
/// </remarks>
|
||||||
public async ValueTask<IList<McpClientTool>> ListToolsAsync(
|
public async ValueTask<IList<McpClientTool>> ListToolsAsync(
|
||||||
RequestOptions? options = null,
|
RequestOptions? options = null,
|
||||||
|
|
@ -247,12 +255,25 @@ public abstract partial class McpClient : McpSession
|
||||||
{
|
{
|
||||||
Throw.IfNull(requestParams);
|
Throw.IfNull(requestParams);
|
||||||
|
|
||||||
return SendRequestAsync(
|
return ValidateCacheableResultAsync(RequestMethods.ToolsList, SendRequestAsync(
|
||||||
RequestMethods.ToolsList,
|
RequestMethods.ToolsList,
|
||||||
requestParams,
|
requestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
|
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListToolsResult,
|
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>
|
/// <summary>
|
||||||
|
|
@ -263,10 +284,18 @@ public abstract partial class McpClient : McpSession
|
||||||
/// <returns>A list of all available prompts as <see cref="McpClientPrompt"/> instances.</returns>
|
/// <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>
|
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
|
/// 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,
|
/// (<see cref="ListPromptsResult.TimeToLive"/> and <see cref="ListPromptsResult.CacheScope"/>). To read those hints,
|
||||||
/// use the <see cref="ListPromptsAsync(ListPromptsRequestParams, CancellationToken)"/> overload, which returns the
|
/// use the <see cref="ListPromptsAsync(ListPromptsRequestParams, CancellationToken)"/> overload, which returns the
|
||||||
/// raw <see cref="ListPromptsResult"/> for each page.
|
/// 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>
|
/// </remarks>
|
||||||
public async ValueTask<IList<McpClientPrompt>> ListPromptsAsync(
|
public async ValueTask<IList<McpClientPrompt>> ListPromptsAsync(
|
||||||
RequestOptions? options = null,
|
RequestOptions? options = null,
|
||||||
|
|
@ -309,12 +338,12 @@ public abstract partial class McpClient : McpSession
|
||||||
{
|
{
|
||||||
Throw.IfNull(requestParams);
|
Throw.IfNull(requestParams);
|
||||||
|
|
||||||
return SendRequestAsync(
|
return ValidateCacheableResultAsync(RequestMethods.PromptsList, SendRequestAsync(
|
||||||
RequestMethods.PromptsList,
|
RequestMethods.PromptsList,
|
||||||
requestParams,
|
requestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams,
|
McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListPromptsResult,
|
McpJsonUtilities.JsonContext.Default.ListPromptsResult,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// <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>
|
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
|
/// 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,
|
/// (<see cref="ListResourceTemplatesResult.TimeToLive"/> and <see cref="ListResourceTemplatesResult.CacheScope"/>). To read those hints,
|
||||||
/// use the <see cref="ListResourceTemplatesAsync(ListResourceTemplatesRequestParams, CancellationToken)"/> overload, which returns the
|
/// use the <see cref="ListResourceTemplatesAsync(ListResourceTemplatesRequestParams, CancellationToken)"/> overload, which returns the
|
||||||
/// raw <see cref="ListResourceTemplatesResult"/> for each page.
|
/// 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>
|
/// </remarks>
|
||||||
public async ValueTask<IList<McpClientResourceTemplate>> ListResourceTemplatesAsync(
|
public async ValueTask<IList<McpClientResourceTemplate>> ListResourceTemplatesAsync(
|
||||||
RequestOptions? options = null,
|
RequestOptions? options = null,
|
||||||
|
|
@ -425,12 +462,12 @@ public abstract partial class McpClient : McpSession
|
||||||
{
|
{
|
||||||
Throw.IfNull(requestParams);
|
Throw.IfNull(requestParams);
|
||||||
|
|
||||||
return SendRequestAsync(
|
return ValidateCacheableResultAsync(RequestMethods.ResourcesTemplatesList, SendRequestAsync(
|
||||||
RequestMethods.ResourcesTemplatesList,
|
RequestMethods.ResourcesTemplatesList,
|
||||||
requestParams,
|
requestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams,
|
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
|
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -441,10 +478,18 @@ public abstract partial class McpClient : McpSession
|
||||||
/// <returns>A list of all available resources as <see cref="Resource"/> instances.</returns>
|
/// <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>
|
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// This overload aggregates every page into a single list and does not surface the per-result caching hints
|
/// 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,
|
/// (<see cref="ListResourcesResult.TimeToLive"/> and <see cref="ListResourcesResult.CacheScope"/>). To read those hints,
|
||||||
/// use the <see cref="ListResourcesAsync(ListResourcesRequestParams, CancellationToken)"/> overload, which returns the
|
/// use the <see cref="ListResourcesAsync(ListResourcesRequestParams, CancellationToken)"/> overload, which returns the
|
||||||
/// raw <see cref="ListResourcesResult"/> for each page.
|
/// 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>
|
/// </remarks>
|
||||||
public async ValueTask<IList<McpClientResource>> ListResourcesAsync(
|
public async ValueTask<IList<McpClientResource>> ListResourcesAsync(
|
||||||
RequestOptions? options = null,
|
RequestOptions? options = null,
|
||||||
|
|
@ -487,12 +532,12 @@ public abstract partial class McpClient : McpSession
|
||||||
{
|
{
|
||||||
Throw.IfNull(requestParams);
|
Throw.IfNull(requestParams);
|
||||||
|
|
||||||
return SendRequestAsync(
|
return ValidateCacheableResultAsync(RequestMethods.ResourcesList, SendRequestAsync(
|
||||||
RequestMethods.ResourcesList,
|
RequestMethods.ResourcesList,
|
||||||
requestParams,
|
requestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams,
|
McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListResourcesResult,
|
McpJsonUtilities.JsonContext.Default.ListResourcesResult,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -571,12 +616,12 @@ public abstract partial class McpClient : McpSession
|
||||||
{
|
{
|
||||||
Throw.IfNull(requestParams);
|
Throw.IfNull(requestParams);
|
||||||
|
|
||||||
return SendRequestAsync(
|
return ValidateCacheableResultAsync(RequestMethods.ResourcesRead, SendRequestAsync(
|
||||||
RequestMethods.ResourcesRead,
|
RequestMethods.ResourcesRead,
|
||||||
requestParams,
|
requestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams,
|
McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ReadResourceResult,
|
McpJsonUtilities.JsonContext.Default.ReadResourceResult,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,20 @@ public abstract partial class McpClient : McpSession
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private protected abstract int MaxConsecutiveStuckPolls { get; }
|
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>
|
/// <summary>
|
||||||
/// Registers one or more tool definitions in the client's tool cache, enabling the transport
|
/// 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.
|
/// 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 SemaphoreSlim _disposeLock = new(1, 1);
|
||||||
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, byte> _registeredToolNames = 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 ServerCapabilities? _serverCapabilities;
|
||||||
private Implementation? _serverInfo;
|
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.")]
|
[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);
|
private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<byte>, 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>ttlMs</c>/<c>cacheScope</c>
|
||||||
|
/// fields, the client logs a warning but never throws.
|
||||||
|
/// </summary>
|
||||||
|
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<object[]> CacheableMethods =>
|
||||||
|
[
|
||||||
|
[RequestMethods.ToolsList],
|
||||||
|
[RequestMethods.PromptsList],
|
||||||
|
[RequestMethods.ResourcesList],
|
||||||
|
[RequestMethods.ResourcesTemplatesList],
|
||||||
|
[RequestMethods.ResourcesRead],
|
||||||
|
];
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(CacheableMethods))]
|
||||||
|
public async Task DraftServerOmittingBothHints_LogsWarning(string method)
|
||||||
|
{
|
||||||
|
var (call, result) = GetScenario(method, ttl: null, scope: null);
|
||||||
|
|
||||||
|
await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, method, result, call, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var warning = Assert.Single(MockLoggerProvider.LogMessages, m =>
|
||||||
|
m.LogLevel == LogLevel.Warning && m.Message.Contains(method) && m.Message.Contains("SEP-2549"));
|
||||||
|
Assert.Contains("ttlMs", warning.Message);
|
||||||
|
Assert.Contains("cacheScope", warning.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DraftServerOmittingOnlyCacheScope_WarnsAboutCacheScope()
|
||||||
|
{
|
||||||
|
var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: null);
|
||||||
|
|
||||||
|
await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var warning = Assert.Single(MockLoggerProvider.LogMessages, m =>
|
||||||
|
m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549"));
|
||||||
|
Assert.Contains("cacheScope", warning.Message);
|
||||||
|
Assert.DoesNotContain("ttlMs", warning.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DraftServerProvidingBothHints_DoesNotWarn()
|
||||||
|
{
|
||||||
|
var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: CacheScope.Public);
|
||||||
|
|
||||||
|
await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m =>
|
||||||
|
m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OlderServerOmittingHints_DoesNotWarn()
|
||||||
|
{
|
||||||
|
// A server on an older protocol version may legitimately omit the fields; no warning should fire.
|
||||||
|
var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: null, scope: null);
|
||||||
|
|
||||||
|
await RunScenarioAsync(OlderProtocolVersion, useModernLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m =>
|
||||||
|
m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoPaginatingOverload_DraftServerOmittingHints_LogsWarning()
|
||||||
|
{
|
||||||
|
// The auto-paginating convenience overload calls the raw overload per page, so the warning
|
||||||
|
// fires through that path as well.
|
||||||
|
var result = JsonSerializer.SerializeToNode(
|
||||||
|
new ListToolsResult { Tools = [new Tool { Name = "echo" }] },
|
||||||
|
McpJsonUtilities.DefaultOptions)!;
|
||||||
|
|
||||||
|
await RunScenarioAsync(
|
||||||
|
July2026ProtocolVersion,
|
||||||
|
useModernLifecycle: true,
|
||||||
|
RequestMethods.ToolsList,
|
||||||
|
result,
|
||||||
|
(c, ct) => c.ListToolsAsync(cancellationToken: ct).AsTask(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Single(MockLoggerProvider.LogMessages, m =>
|
||||||
|
m.LogLevel == LogLevel.Warning && m.Message.Contains(RequestMethods.ToolsList) && m.Message.Contains("SEP-2549"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoPaginatingOverload_MultiplePages_WarnsOnlyOncePerMethod()
|
||||||
|
{
|
||||||
|
// A non-conformant draft server omits the hints on every page. The warning must be emitted at
|
||||||
|
// most once per method per session so that long paginated listings do not flood the log.
|
||||||
|
const int pageCount = 4;
|
||||||
|
|
||||||
|
var clientToServer = new Pipe();
|
||||||
|
var serverToClient = new Pipe();
|
||||||
|
|
||||||
|
var clientTask = McpClient.CreateAsync(
|
||||||
|
new StreamClientTransport(
|
||||||
|
clientToServer.Writer.AsStream(),
|
||||||
|
serverToClient.Reader.AsStream(),
|
||||||
|
LoggerFactory),
|
||||||
|
new McpClientOptions { ProtocolVersion = July2026ProtocolVersion },
|
||||||
|
loggerFactory: LoggerFactory,
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var serverReader = new StreamReader(clientToServer.Reader.AsStream());
|
||||||
|
var serverWriter = serverToClient.Writer.AsStream();
|
||||||
|
|
||||||
|
await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, useModernLifecycle: true, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var client = await clientTask;
|
||||||
|
|
||||||
|
// Respond to each tools/list page omitting the hints, advancing the cursor until the last page.
|
||||||
|
var serverLoop = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
int page = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var line = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JsonSerializer.Deserialize<JsonRpcMessage>(line, McpJsonUtilities.DefaultOptions) is JsonRpcRequest request &&
|
||||||
|
request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
page++;
|
||||||
|
var result = new ListToolsResult
|
||||||
|
{
|
||||||
|
Tools = [new Tool { Name = $"tool{page}" }],
|
||||||
|
NextCursor = page < pageCount ? $"page{page}" : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
Result = JsonSerializer.SerializeToNode(result, McpJsonUtilities.DefaultOptions),
|
||||||
|
}, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
if (page >= pageCount)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
await serverLoop;
|
||||||
|
|
||||||
|
Assert.Equal(pageCount, tools.Count);
|
||||||
|
Assert.Single(MockLoggerProvider.LogMessages, m =>
|
||||||
|
m.LogLevel == LogLevel.Warning && m.Message.Contains(RequestMethods.ToolsList) && m.Message.Contains("SEP-2549"));
|
||||||
|
|
||||||
|
clientToServer.Writer.Complete();
|
||||||
|
serverToClient.Writer.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Func<McpClient, CancellationToken, Task> Call, JsonNode Result) GetScenario(
|
||||||
|
string method, TimeSpan? ttl, CacheScope? scope)
|
||||||
|
{
|
||||||
|
var options = McpJsonUtilities.DefaultOptions;
|
||||||
|
return method switch
|
||||||
|
{
|
||||||
|
RequestMethods.ToolsList => (
|
||||||
|
(c, ct) => c.ListToolsAsync(new ListToolsRequestParams(), ct).AsTask(),
|
||||||
|
JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [], TimeToLive = ttl, CacheScope = scope }, options)!),
|
||||||
|
RequestMethods.PromptsList => (
|
||||||
|
(c, ct) => c.ListPromptsAsync(new ListPromptsRequestParams(), ct).AsTask(),
|
||||||
|
JsonSerializer.SerializeToNode(new ListPromptsResult { Prompts = [], TimeToLive = ttl, CacheScope = scope }, options)!),
|
||||||
|
RequestMethods.ResourcesList => (
|
||||||
|
(c, ct) => c.ListResourcesAsync(new ListResourcesRequestParams(), ct).AsTask(),
|
||||||
|
JsonSerializer.SerializeToNode(new ListResourcesResult { Resources = [], TimeToLive = ttl, CacheScope = scope }, options)!),
|
||||||
|
RequestMethods.ResourcesTemplatesList => (
|
||||||
|
(c, ct) => c.ListResourceTemplatesAsync(new ListResourceTemplatesRequestParams(), ct).AsTask(),
|
||||||
|
JsonSerializer.SerializeToNode(new ListResourceTemplatesResult { ResourceTemplates = [], TimeToLive = ttl, CacheScope = scope }, options)!),
|
||||||
|
RequestMethods.ResourcesRead => (
|
||||||
|
(c, ct) => c.ReadResourceAsync(new ReadResourceRequestParams { Uri = "test://resource" }, ct).AsTask(),
|
||||||
|
JsonSerializer.SerializeToNode(new ReadResourceResult { Contents = [], TimeToLive = ttl, CacheScope = scope }, options)!),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unhandled method."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunScenarioAsync(
|
||||||
|
string serverProtocolVersion,
|
||||||
|
bool useModernLifecycle,
|
||||||
|
string method,
|
||||||
|
JsonNode resultNode,
|
||||||
|
Func<McpClient, CancellationToken, Task> clientCall,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var clientToServer = new Pipe();
|
||||||
|
var serverToClient = new Pipe();
|
||||||
|
|
||||||
|
// Pin the protocol version so the client deterministically takes the modern (server/discover)
|
||||||
|
// lifecycle for 2026-07-28 and the legacy (initialize) lifecycle for older versions.
|
||||||
|
var clientTask = McpClient.CreateAsync(
|
||||||
|
new StreamClientTransport(
|
||||||
|
clientToServer.Writer.AsStream(),
|
||||||
|
serverToClient.Reader.AsStream(),
|
||||||
|
LoggerFactory),
|
||||||
|
new McpClientOptions { ProtocolVersion = serverProtocolVersion },
|
||||||
|
loggerFactory: LoggerFactory,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var serverReader = new StreamReader(clientToServer.Reader.AsStream());
|
||||||
|
var serverWriter = serverToClient.Writer.AsStream();
|
||||||
|
|
||||||
|
await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, useModernLifecycle, cancellationToken);
|
||||||
|
|
||||||
|
await using var client = await clientTask;
|
||||||
|
Assert.Equal(serverProtocolVersion, client.NegotiatedProtocolVersion);
|
||||||
|
|
||||||
|
// Background server loop: respond to the target request with the crafted result.
|
||||||
|
var serverLoop = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var line = await serverReader.ReadLineAsync(cancellationToken);
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JsonSerializer.Deserialize<JsonRpcMessage>(line, McpJsonUtilities.DefaultOptions) is JsonRpcRequest request &&
|
||||||
|
request.Method == method)
|
||||||
|
{
|
||||||
|
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
Result = resultNode,
|
||||||
|
}, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await clientCall(client, cancellationToken);
|
||||||
|
await serverLoop;
|
||||||
|
|
||||||
|
clientToServer.Writer.Complete();
|
||||||
|
serverToClient.Writer.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PerformHandshakeAsync(
|
||||||
|
StreamReader serverReader,
|
||||||
|
Stream serverWriter,
|
||||||
|
string serverProtocolVersion,
|
||||||
|
bool useModernLifecycle,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestLine = await serverReader.ReadLineAsync(cancellationToken);
|
||||||
|
Assert.NotNull(requestLine);
|
||||||
|
var request = JsonSerializer.Deserialize<JsonRpcRequest>(requestLine, McpJsonUtilities.DefaultOptions);
|
||||||
|
Assert.NotNull(request);
|
||||||
|
|
||||||
|
if (useModernLifecycle)
|
||||||
|
{
|
||||||
|
// Modern 2026-07-28 lifecycle (SEP-2575): no initialize handshake. The client probes
|
||||||
|
// server/discover to learn capabilities, then sends normal RPCs carrying per-request _meta.
|
||||||
|
Assert.Equal(RequestMethods.ServerDiscover, request.Method);
|
||||||
|
|
||||||
|
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||||
|
{
|
||||||
|
SupportedVersions = [serverProtocolVersion],
|
||||||
|
Capabilities = new ServerCapabilities(),
|
||||||
|
ServerInfo = new Implementation { Name = "MockServer", Version = "1.0" },
|
||||||
|
}, McpJsonUtilities.DefaultOptions),
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Legacy initialize handshake for older protocol versions.
|
||||||
|
Assert.Equal(RequestMethods.Initialize, request.Method);
|
||||||
|
|
||||||
|
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||||
|
{
|
||||||
|
ProtocolVersion = serverProtocolVersion,
|
||||||
|
Capabilities = new ServerCapabilities(),
|
||||||
|
ServerInfo = new Implementation { Name = "MockServer", Version = "1.0" },
|
||||||
|
}, McpJsonUtilities.DefaultOptions),
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
// Consume the initialized notification.
|
||||||
|
var initializedLine = await serverReader.ReadLineAsync(cancellationToken);
|
||||||
|
Assert.NotNull(initializedLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteJsonRpcAsync(Stream writer, JsonRpcMessage message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var bytes = JsonSerializer.SerializeToUtf8Bytes<JsonRpcMessage>(message, McpJsonUtilities.DefaultOptions);
|
||||||
|
await writer.WriteAsync(bytes, cancellationToken);
|
||||||
|
await writer.WriteAsync("\n"u8.ToArray(), cancellationToken);
|
||||||
|
await writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
Loading…
Reference in a new issue