Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)
This commit is contained in:
parent
3368bb7349
commit
6c323e783b
35 changed files with 5704 additions and 712 deletions
|
|
@ -0,0 +1,91 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the draft protocol revision (SEP-2575 + SEP-2567) connection flow on
|
||||
/// <see cref="McpClient"/> — the client should call <c>server/discover</c> instead of
|
||||
/// <c>initialize</c> when <see cref="McpClientOptions.ProtocolVersion"/> is set to
|
||||
/// <see cref="McpSessionHandler.DraftProtocolVersion"/>.
|
||||
/// </summary>
|
||||
public class DraftConnectionTests : ClientServerTestBase
|
||||
{
|
||||
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
|
||||
private const string LatestStableVersion = "2025-11-25";
|
||||
|
||||
public DraftConnectionTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper, startServer: false)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.ServerInfo = new Implementation { Name = nameof(DraftConnectionTests), Version = "1.0" };
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_ConnectingToDraftServer_NegotiatesDraftVersion()
|
||||
{
|
||||
StartServer();
|
||||
|
||||
var options = new McpClientOptions { ProtocolVersion = DraftVersion };
|
||||
await using var client = await CreateMcpClientForServer(options);
|
||||
|
||||
Assert.Equal(DraftVersion, client.NegotiatedProtocolVersion);
|
||||
Assert.NotNull(client.ServerCapabilities);
|
||||
Assert.Equal(nameof(DraftConnectionTests), client.ServerInfo.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion()
|
||||
{
|
||||
StartServer();
|
||||
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
|
||||
Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_CanCallServerDiscover()
|
||||
{
|
||||
// server/discover is registered unconditionally, so a legacy client can probe it
|
||||
// (e.g., to learn capabilities without doing a second initialize).
|
||||
StartServer();
|
||||
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
|
||||
var response = await client.SendRequestAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.ServerDiscover },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var discoverResult = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(discoverResult);
|
||||
Assert.NotEmpty(discoverResult.SupportedVersions);
|
||||
Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions);
|
||||
Assert.Equal(nameof(DraftConnectionTests), discoverResult.ServerInfo.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftServer_DiscoverIncludesDraftVersion()
|
||||
{
|
||||
StartServer();
|
||||
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var response = await client.SendRequestAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.ServerDiscover },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var discoverResult = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(discoverResult);
|
||||
Assert.Contains(DraftVersion, discoverResult.SupportedVersions);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the C# client emits the SEP-2575 <c>_meta</c> envelope on every list-style
|
||||
/// request (and on <c>server/discover</c>) under the draft protocol revision, even when the
|
||||
/// caller supplies no <c>RequestOptions</c> / no params.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Spec PR #2759 promotes <c>params._meta</c> to required on <c>tools/list</c>,
|
||||
/// <c>resources/list</c>, <c>resources/templates/list</c>, <c>prompts/list</c>, and
|
||||
/// <c>server/discover</c> under draft. This test class drives the C# client through
|
||||
/// <see cref="ClientServerTestBase"/> with the draft revision negotiated, attaches a request
|
||||
/// filter on each list endpoint that captures the incoming <c>_meta</c> envelope, and asserts
|
||||
/// the three required SEP-2575 keys are present:
|
||||
/// <c>io.modelcontextprotocol/protocolVersion</c>,
|
||||
/// <c>io.modelcontextprotocol/clientInfo</c>, and
|
||||
/// <c>io.modelcontextprotocol/clientCapabilities</c>.
|
||||
/// </remarks>
|
||||
public class DraftListMetaEmissionTests : ClientServerTestBase
|
||||
{
|
||||
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
|
||||
private const string LatestStableVersion = "2025-11-25";
|
||||
|
||||
// Captured _meta envelopes for each request method we exercise. Populated by the per-method
|
||||
// server-side filters and asserted from each test method.
|
||||
private readonly Dictionary<string, JsonObject?> _capturedMeta = new(StringComparer.Ordinal);
|
||||
|
||||
public DraftListMetaEmissionTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper, startServer: false)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.WithRequestFilters(filters =>
|
||||
{
|
||||
filters.AddListToolsFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
_capturedMeta[RequestMethods.ToolsList] = request.Params?.Meta;
|
||||
return await next(request, cancellationToken);
|
||||
});
|
||||
filters.AddListPromptsFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
_capturedMeta[RequestMethods.PromptsList] = request.Params?.Meta;
|
||||
return await next(request, cancellationToken);
|
||||
});
|
||||
filters.AddListResourcesFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
_capturedMeta[RequestMethods.ResourcesList] = request.Params?.Meta;
|
||||
return await next(request, cancellationToken);
|
||||
});
|
||||
filters.AddListResourceTemplatesFilter(next => async (request, cancellationToken) =>
|
||||
{
|
||||
_capturedMeta[RequestMethods.ResourcesTemplatesList] = request.Params?.Meta;
|
||||
return await next(request, cancellationToken);
|
||||
});
|
||||
});
|
||||
|
||||
// No-op list handlers (so the requests complete) — content is irrelevant; we only assert the
|
||||
// incoming envelope.
|
||||
mcpServerBuilder
|
||||
.WithListToolsHandler((_, _) => new ValueTask<ListToolsResult>(new ListToolsResult { Tools = [] }))
|
||||
.WithListPromptsHandler((_, _) => new ValueTask<ListPromptsResult>(new ListPromptsResult { Prompts = [] }))
|
||||
.WithListResourcesHandler((_, _) => new ValueTask<ListResourcesResult>(new ListResourcesResult { Resources = [] }))
|
||||
.WithListResourceTemplatesHandler((_, _) => new ValueTask<ListResourceTemplatesResult>(
|
||||
new ListResourceTemplatesResult { ResourceTemplates = [] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_ListTools_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion });
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
AssertDraftMetaPresent(RequestMethods.ToolsList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_ListPrompts_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion });
|
||||
|
||||
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
AssertDraftMetaPresent(RequestMethods.PromptsList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_ListResources_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion });
|
||||
|
||||
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
AssertDraftMetaPresent(RequestMethods.ResourcesList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_ListResourceTemplates_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion });
|
||||
|
||||
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
AssertDraftMetaPresent(RequestMethods.ResourcesTemplatesList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_ServerDiscover_EmitsRequiredMeta()
|
||||
{
|
||||
// server/discover has no public List-style helper; we drive it via SendRequestAsync directly,
|
||||
// which still flows through the client's draft-meta injector.
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion });
|
||||
|
||||
// Hook the server-side handler invocation via a notification handler is awkward here; assert
|
||||
// instead by sending the request and parsing the wire-shape echo from the response context.
|
||||
// Easier path: rely on the existing JsonRpcRequest capture in the message context — see the
|
||||
// raw conformance tests for the wire-level proof. For this in-process test, we instead drive
|
||||
// the request and rely on the response being a valid DiscoverResult; the draft meta injector
|
||||
// would otherwise have failed the server's per-request envelope validation.
|
||||
var response = await client.SendRequestAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.ServerDiscover },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(response.Result);
|
||||
var discover = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Contains(DraftVersion, discover.SupportedVersions);
|
||||
|
||||
// The server enforces draft envelope shape per request; if the client had omitted _meta, the
|
||||
// request would have failed with -32602 / -32003 rather than returning a DiscoverResult. The
|
||||
// successful round-trip is the assertion.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_ListTools_DoesNotEmitDraftMeta()
|
||||
{
|
||||
// Sanity guard: the legacy (non-draft) client must NOT emit the SEP-2575 envelope — the meta
|
||||
// injector is gated on the negotiated protocol version. If this ever started writing draft keys
|
||||
// under legacy protocols, every legacy server would reject the request.
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var meta = _capturedMeta[RequestMethods.ToolsList];
|
||||
if (meta is not null)
|
||||
{
|
||||
Assert.False(meta.ContainsKey(MetaKeys.ProtocolVersion));
|
||||
Assert.False(meta.ContainsKey(MetaKeys.ClientInfo));
|
||||
Assert.False(meta.ContainsKey(MetaKeys.ClientCapabilities));
|
||||
}
|
||||
}
|
||||
|
||||
private void AssertDraftMetaPresent(string method)
|
||||
{
|
||||
Assert.True(_capturedMeta.TryGetValue(method, out var meta), $"No capture for {method}");
|
||||
Assert.NotNull(meta);
|
||||
Assert.True(meta!.ContainsKey(MetaKeys.ProtocolVersion),
|
||||
$"Missing protocolVersion key on {method} _meta envelope");
|
||||
Assert.True(meta.ContainsKey(MetaKeys.ClientInfo),
|
||||
$"Missing clientInfo key on {method} _meta envelope");
|
||||
Assert.True(meta.ContainsKey(MetaKeys.ClientCapabilities),
|
||||
$"Missing clientCapabilities key on {method} _meta envelope");
|
||||
|
||||
// The protocolVersion value must match the negotiated draft version.
|
||||
Assert.Equal(DraftVersion, meta[MetaKeys.ProtocolVersion]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the draft-protocol-to-legacy fallback path in
|
||||
/// <see cref="McpClient"/>. These verify that a client configured with
|
||||
/// <c>McpClientOptions.ProtocolVersion = McpSession.DraftProtocolVersion</c>
|
||||
/// correctly probes for a draft-aware server with <c>server/discover</c>, falls
|
||||
/// back to the legacy <c>initialize</c> handshake when the server is legacy,
|
||||
/// and accepts whatever supported protocol version the legacy server
|
||||
/// negotiates - including a version different from the one the client
|
||||
/// originally requested.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The originally shipped logic in <c>PerformLegacyInitializeAsync</c> compared
|
||||
/// the server's response against <c>_options.ProtocolVersion</c>, which under
|
||||
/// draft is <c>"2026-07-28"</c>. When the legacy server downgraded to (say)
|
||||
/// <c>"2025-06-18"</c>, the comparison threw, even though the legacy
|
||||
/// negotiation succeeded. These tests guard against that regression.
|
||||
/// </remarks>
|
||||
public class DraftProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DraftClient_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18");
|
||||
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.True(transport.ServerDiscoverProbed);
|
||||
Assert.True(transport.LegacyInitializeReceived);
|
||||
Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_OnInvalidParams_FallsBackTo_Initialize()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(
|
||||
serverNegotiatedVersion: "2025-11-25",
|
||||
probeErrorCode: (int)McpErrorCode.InvalidParams);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.True(transport.LegacyInitializeReceived);
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_WithMinProtocolVersion_RefusesFallback_BelowMinimum()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18");
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
MinProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
Assert.IsType<McpException>(exception);
|
||||
Assert.Contains("minimum", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
// Server responds with a DIFFERENT version than the one the user pinned.
|
||||
await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26");
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = "2025-11-25",
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
Assert.IsType<McpException>(exception);
|
||||
Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_OnHeaderMismatch_Surfaces_NoFallback()
|
||||
{
|
||||
// The peer is modern (returns the spec-defined -32001 HeaderMismatch on the probe).
|
||||
// Falling back to legacy initialize would just produce another malformed envelope.
|
||||
// Verify the connect-time logic surfaces the error to the caller instead of falling back.
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(
|
||||
serverNegotiatedVersion: "2025-11-25",
|
||||
probeErrorCode: (int)McpErrorCode.HeaderMismatch);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<McpException>(async () =>
|
||||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
Assert.True(transport.ServerDiscoverProbed);
|
||||
Assert.False(transport.LegacyInitializeReceived);
|
||||
Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout()
|
||||
{
|
||||
// Simulate a legacy server that silently drops the unknown server/discover method (it never
|
||||
// responds to the probe). The client must fall back to legacy initialize once the configured
|
||||
// DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout.
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(
|
||||
serverNegotiatedVersion: "2025-11-25",
|
||||
silentDiscoverProbe: true);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
DiscoverProbeTimeout = TimeSpan.FromMilliseconds(250),
|
||||
InitializationTimeout = TestConstants.DefaultTimeout,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.True(transport.ServerDiscoverProbed);
|
||||
Assert.True(transport.LegacyInitializeReceived);
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
|
||||
// The fallback was driven by the short probe timeout, not the 60s InitializationTimeout.
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(30),
|
||||
$"Fallback should have happened shortly after the {nameof(McpClientOptions.DiscoverProbeTimeout)}, but took {stopwatch.Elapsed}.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1000)]
|
||||
public void DiscoverProbeTimeout_Setter_Rejects_NonPositiveValues(int milliseconds)
|
||||
{
|
||||
var options = new McpClientOptions();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => options.DiscoverProbeTimeout = TimeSpan.FromMilliseconds(milliseconds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues()
|
||||
{
|
||||
var options = new McpClientOptions();
|
||||
|
||||
// Default is the documented 5 seconds.
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), options.DiscoverProbeTimeout);
|
||||
|
||||
options.DiscoverProbeTimeout = TimeSpan.FromSeconds(30);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.DiscoverProbeTimeout);
|
||||
|
||||
// Timeout.InfiniteTimeSpan disables the separate probe timeout (bounded by InitializationTimeout only).
|
||||
options.DiscoverProbeTimeout = Timeout.InfiniteTimeSpan;
|
||||
Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory transport that simulates a legacy server: rejects
|
||||
/// <c>server/discover</c> (with a configurable JSON-RPC error code, or by
|
||||
/// silently dropping the request) and responds to <c>initialize</c> with a
|
||||
/// configurable protocol version.
|
||||
/// </summary>
|
||||
private sealed class LegacyServerTestTransport(
|
||||
string serverNegotiatedVersion,
|
||||
int probeErrorCode = (int)McpErrorCode.MethodNotFound,
|
||||
bool silentDiscoverProbe = false) : IClientTransport
|
||||
{
|
||||
private readonly Channel<JsonRpcMessage> _incomingToClient = Channel.CreateUnbounded<JsonRpcMessage>();
|
||||
|
||||
public string Name => "legacy-server-test-transport";
|
||||
|
||||
public bool ServerDiscoverProbed { get; private set; }
|
||||
|
||||
public bool LegacyInitializeReceived { get; private set; }
|
||||
|
||||
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ITransport transport = new TransportChannel(_incomingToClient, this);
|
||||
return Task.FromResult(transport);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => default;
|
||||
|
||||
private void HandleOutgoingMessage(JsonRpcMessage message)
|
||||
{
|
||||
switch (message)
|
||||
{
|
||||
case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq:
|
||||
ServerDiscoverProbed = true;
|
||||
if (silentDiscoverProbe)
|
||||
{
|
||||
// Model a legacy server that drops the unknown method without replying.
|
||||
break;
|
||||
}
|
||||
|
||||
_ = WriteAsync(new JsonRpcError
|
||||
{
|
||||
Id = discoverReq.Id,
|
||||
Error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = probeErrorCode,
|
||||
Message = probeErrorCode == (int)McpErrorCode.MethodNotFound
|
||||
? "Method not found"
|
||||
: "Invalid params",
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case JsonRpcRequest { Method: RequestMethods.Initialize } initReq:
|
||||
LegacyInitializeReceived = true;
|
||||
_ = WriteAsync(new JsonRpcResponse
|
||||
{
|
||||
Id = initReq.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = serverNegotiatedVersion,
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteAsync(JsonRpcMessage message)
|
||||
=> _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask();
|
||||
|
||||
private sealed class TransportChannel(
|
||||
Channel<JsonRpcMessage> incoming,
|
||||
LegacyServerTestTransport parent) : ITransport
|
||||
{
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => incoming.Reader;
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
public string? SessionId => null;
|
||||
|
||||
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
parent.HandleOutgoingMessage(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
incoming.Writer.TryComplete();
|
||||
IsConnected = false;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -171,10 +171,27 @@ public class McpClientCreationTests(ITestOutputHelper testOutputHelper) : Logged
|
|||
{
|
||||
switch (message)
|
||||
{
|
||||
case JsonRpcRequest:
|
||||
case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverRequest:
|
||||
_channel.Writer.TryWrite(new JsonRpcResponse
|
||||
{
|
||||
Id = ((JsonRpcRequest)message).Id,
|
||||
Id = discoverRequest.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
Capabilities = new ServerCapabilities(),
|
||||
SupportedVersions = [McpSession.DraftProtocolVersion],
|
||||
ServerInfo = new Implementation
|
||||
{
|
||||
Name = "NopTransport",
|
||||
Version = "1.0.0"
|
||||
},
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
});
|
||||
break;
|
||||
|
||||
case JsonRpcRequest request:
|
||||
_channel.Writer.TryWrite(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
Capabilities = new ServerCapabilities(),
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
public class McpClientMetaTests : ClientServerTestBase
|
||||
{
|
||||
// InitializeMeta is carried on the legacy initialize request, which the draft revision removes.
|
||||
// The two InitializeMeta_* tests pin to the latest stable version so the handshake actually runs.
|
||||
private const string LatestStableVersion = "2025-11-25";
|
||||
|
||||
private readonly TaskCompletionSource<JsonNode?> _initializeMeta = new();
|
||||
|
||||
public McpClientMetaTests(ITestOutputHelper outputHelper)
|
||||
|
|
@ -50,6 +55,7 @@ public class McpClientMetaTests : ClientServerTestBase
|
|||
{
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = LatestStableVersion,
|
||||
InitializeMeta = new JsonObject
|
||||
{
|
||||
{ "foo", "bar baz" }
|
||||
|
|
@ -58,7 +64,7 @@ public class McpClientMetaTests : ClientServerTestBase
|
|||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var meta = await _initializeMeta.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(meta);
|
||||
Assert.Equal("bar baz", meta["foo"]?.ToString());
|
||||
|
|
@ -67,9 +73,9 @@ public class McpClientMetaTests : ClientServerTestBase
|
|||
[Fact]
|
||||
public async Task InitializeMeta_IsOmitted_WhenNotSet()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
|
||||
var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var meta = await _initializeMeta.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(meta);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -584,15 +584,16 @@ public class McpClientTests : ClientServerTestBase
|
|||
public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion)
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = protocolVersion });
|
||||
Assert.Equal(protocolVersion ?? "2025-11-25", client.NegotiatedProtocolVersion);
|
||||
// A null ProtocolVersion now prefers the draft revision, which the reactive test server advertises.
|
||||
Assert.Equal(protocolVersion ?? "2026-07-28", client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol()
|
||||
{
|
||||
Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1";
|
||||
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" });
|
||||
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
|
||||
Server.ServerOptions.ProtocolVersion = "2026-07-28";
|
||||
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2026-07-28" });
|
||||
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -794,7 +795,9 @@ public class McpClientTests : ClientServerTestBase
|
|||
[Fact]
|
||||
public async Task ServerCanPingClient()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
// ping is a legacy-only RPC (removed in the draft revision per SEP-2575), so pin the client
|
||||
// to a legacy protocol version to exercise the server-initiated ping round-trip.
|
||||
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" });
|
||||
|
||||
var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping };
|
||||
var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ public class McpRequestHeadersTests
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DRAFT-2026-v1", true)]
|
||||
[InlineData("2026-07-28", true)]
|
||||
[InlineData("2025-11-25", false)]
|
||||
[InlineData("2025-06-18", false)]
|
||||
[InlineData("2024-11-05", false)]
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.ProtocolVersion = "DRAFT-2026-v1";
|
||||
options.ProtocolVersion = "2026-07-28";
|
||||
_messageTracker.AddFilters(options.Filters.Message);
|
||||
});
|
||||
|
||||
|
|
@ -95,14 +95,14 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
// input resolution failures back to the server.
|
||||
StartServer();
|
||||
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
throw new InvalidOperationException("Client-side elicitation failure");
|
||||
};
|
||||
|
||||
await using var client = await CreateMcpClientForServer(clientOptions);
|
||||
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
|
||||
|
||||
// The client handler throws during input resolution, so the exception
|
||||
// escapes ResolveInputRequestAsync and surfaces directly to the caller.
|
||||
|
|
@ -130,7 +130,7 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
// SendMessageAsync should throw InvalidOperationException if the message is a
|
||||
// JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests.
|
||||
StartServer();
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
var clientToServer = new Pipe();
|
||||
var serverToClient = new Pipe();
|
||||
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
|
||||
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
|
||||
|
|
@ -165,7 +165,7 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
Model = "test-model"
|
||||
});
|
||||
|
||||
// Start the client task - it will send initialize and block waiting for response
|
||||
// Start the client task — it will send server/discover (draft) and block waiting for response
|
||||
var clientTask = McpClient.CreateAsync(
|
||||
new StreamClientTransport(
|
||||
clientToServer.Writer.AsStream(),
|
||||
|
|
@ -175,37 +175,34 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Simulate server: read initialize request, respond with experimental version
|
||||
// Simulate server: read server/discover request, respond with a DiscoverResult
|
||||
// that advertises support for the experimental version.
|
||||
var serverReader = new StreamReader(clientToServer.Reader.AsStream());
|
||||
var serverWriter = serverToClient.Writer.AsStream();
|
||||
|
||||
// Read the initialize request from client
|
||||
var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(initLine);
|
||||
var initRequest = JsonSerializer.Deserialize<JsonRpcRequest>(initLine, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(initRequest);
|
||||
Assert.Equal("initialize", initRequest.Method);
|
||||
// Read the server/discover request from client (draft revision skips initialize per SEP-2575).
|
||||
var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(discoverLine);
|
||||
var discoverRequest = JsonSerializer.Deserialize<JsonRpcRequest>(discoverLine, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(discoverRequest);
|
||||
Assert.Equal(RequestMethods.ServerDiscover, discoverRequest.Method);
|
||||
|
||||
// Respond with experimental protocol version (MRTR negotiated)
|
||||
var initResponse = new JsonRpcResponse
|
||||
// Respond with a DiscoverResult that includes the experimental version in supportedVersions.
|
||||
var discoverResponse = new JsonRpcResponse
|
||||
{
|
||||
Id = initRequest.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
Id = discoverRequest.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
ProtocolVersion = "DRAFT-2026-v1",
|
||||
SupportedVersions = new List<string> { "2026-07-28" },
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }
|
||||
ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
await WriteJsonRpcAsync(serverWriter, initResponse);
|
||||
await WriteJsonRpcAsync(serverWriter, discoverResponse);
|
||||
|
||||
// Read the initialized notification from client
|
||||
var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(initializedLine);
|
||||
|
||||
// Client is now connected with MRTR negotiated
|
||||
// Client is now connected with MRTR negotiated (no initialized notification under draft).
|
||||
await using var client = await clientTask;
|
||||
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
|
||||
|
||||
// Now simulate the non-compliant server sending a legacy elicitation/create request
|
||||
var legacyRequest = new JsonRpcRequest
|
||||
|
|
@ -253,8 +250,9 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
var clientToServer = new Pipe();
|
||||
var serverToClient = new Pipe();
|
||||
|
||||
// Client does NOT set DRAFT-2026-v1 - standard protocol only
|
||||
var clientOptions = new McpClientOptions();
|
||||
// Client is pinned to a legacy protocol version, so it performs the initialize
|
||||
// handshake and the session is treated as non-MRTR.
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2025-03-26" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult
|
||||
{
|
||||
|
|
@ -408,7 +406,9 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
var clientToServer = new Pipe();
|
||||
var serverToClient = new Pipe();
|
||||
|
||||
var clientOptions = new McpClientOptions();
|
||||
// Pin to the draft revision so the client performs the server/discover handshake and
|
||||
// treats InputRequiredResult as an MRTR round-trip.
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (_, _) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult
|
||||
{
|
||||
|
|
@ -431,30 +431,27 @@ public class MrtrIntegrationTests : ClientServerTestBase
|
|||
var serverReader = new StreamReader(clientToServer.Reader.AsStream());
|
||||
var serverWriter = serverToClient.Writer.AsStream();
|
||||
|
||||
// Initialize handshake - negotiate DRAFT-2026-v1 so the client treats InputRequiredResult as MRTR.
|
||||
var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(initLine);
|
||||
var initRequest = JsonSerializer.Deserialize<JsonRpcRequest>(initLine, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(initRequest);
|
||||
Assert.Equal("initialize", initRequest.Method);
|
||||
// server/discover handshake - negotiate 2026-07-28 so the client treats InputRequiredResult as MRTR.
|
||||
var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(discoverLine);
|
||||
var discoverRequest = JsonSerializer.Deserialize<JsonRpcRequest>(discoverLine, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(discoverRequest);
|
||||
Assert.Equal("server/discover", discoverRequest.Method);
|
||||
|
||||
var initResponse = new JsonRpcResponse
|
||||
var discoverResponse = new JsonRpcResponse
|
||||
{
|
||||
Id = initRequest.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
Id = discoverRequest.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
ProtocolVersion = "DRAFT-2026-v1",
|
||||
SupportedVersions = ["2026-07-28"],
|
||||
Capabilities = new ServerCapabilities { Tools = new() },
|
||||
ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" }
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
await WriteJsonRpcAsync(serverWriter, initResponse);
|
||||
|
||||
var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(initializedLine);
|
||||
await WriteJsonRpcAsync(serverWriter, discoverResponse);
|
||||
|
||||
await using var client = await clientTask;
|
||||
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
|
||||
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIn
|
|||
// Arrange
|
||||
|
||||
// Act
|
||||
await using var client = await _fixture.CreateClientAsync(clientId);
|
||||
// ping was removed in the draft revision (SEP-2575), so pin to the latest stable
|
||||
// protocol version to keep exercising the legacy ping RPC. Draft liveness relies on
|
||||
// the transport/request lifecycle instead of an explicit ping.
|
||||
await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = "2025-11-25" });
|
||||
await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ namespace ModelContextProtocol.Tests.Configuration;
|
|||
|
||||
public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper, startServer: false)
|
||||
{
|
||||
private const string LatestStableVersion = "2025-11-25";
|
||||
|
||||
private static ILogger GetLogger(IServiceProvider? services, string categoryName)
|
||||
{
|
||||
var loggerFactory = services?.GetRequiredService<ILoggerFactory>() ?? throw new InvalidOperationException("LoggerFactory not available");
|
||||
|
|
@ -72,22 +74,25 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
{
|
||||
List<string> messageTypes = [];
|
||||
|
||||
// The client sends notifications/initialized fire-and-forget, so unlike the initialize and
|
||||
// tools/list request/response exchanges it has no synchronization point the test can await.
|
||||
// Signal once the filter finishes processing it so the strict counts below observe a
|
||||
// complete, stable log instead of racing the still-in-flight notification.
|
||||
var initializedNotificationProcessed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
// Under the draft protocol the client performs a server/discover + tools/list exchange (no
|
||||
// fire-and-forget initialized notification), so the tools/list request is a deterministic
|
||||
// synchronization point. Gate recording to it and signal once the filter finishes so a
|
||||
// regression that invokes the filter pipeline more than once per message surfaces as an extra entry.
|
||||
var toolsListProcessed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
McpServerBuilder
|
||||
.WithMessageFilters(filters => filters.AddIncomingFilter((next) => async (context, cancellationToken) =>
|
||||
{
|
||||
var messageTypeName = context.JsonRpcMessage.GetType().Name;
|
||||
messageTypes.Add(messageTypeName);
|
||||
if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList })
|
||||
{
|
||||
messageTypes.Add(context.JsonRpcMessage.GetType().Name);
|
||||
}
|
||||
|
||||
await next(context, cancellationToken);
|
||||
|
||||
if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification })
|
||||
if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList })
|
||||
{
|
||||
initializedNotificationProcessed.TrySetResult(true);
|
||||
toolsListProcessed.TrySetResult(true);
|
||||
}
|
||||
}))
|
||||
.WithTools<TestTool>();
|
||||
|
|
@ -98,51 +103,57 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for the fire-and-forget initialized notification to flow through the filter pipeline
|
||||
// before snapshotting the counts; otherwise the strict counts below can race the notification.
|
||||
await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
await toolsListProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
|
||||
// The message filter should intercept JsonRpcRequest messages.
|
||||
// Use strict counts so a regression that invokes the filter pipeline more than once per
|
||||
// incoming message (analogous to the SendRequestAsync double-wrap regression on the outgoing
|
||||
// side) would fail this test instead of slipping through Assert.Contains.
|
||||
// A single ListToolsAsync drives three server-bound messages: initialize (request),
|
||||
// notifications/initialized (notification), and tools/list (request).
|
||||
Assert.Equal(2, messageTypes.Count(m => m == nameof(JsonRpcRequest)));
|
||||
Assert.Equal(1, messageTypes.Count(m => m == nameof(JsonRpcNotification)));
|
||||
// The message filter should intercept the tools/list JsonRpcRequest exactly once.
|
||||
Assert.Collection(messageTypes, m => Assert.Equal(nameof(JsonRpcRequest), m));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order()
|
||||
{
|
||||
// The client sends notifications/initialized fire-and-forget, so unlike the initialize and
|
||||
// tools/list request/response exchanges it has no synchronization point the test can await.
|
||||
// Signal once the outermost filter finishes processing it so the strict counts below observe a
|
||||
// complete, stable log instead of racing the still-in-flight notification.
|
||||
var initializedNotificationProcessed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
// Under the draft protocol the client performs a server/discover + tools/list exchange (no
|
||||
// fire-and-forget initialized notification), so the tools/list request is a deterministic
|
||||
// synchronization point. Gate the filter logging to it and signal once the outermost filter
|
||||
// finishes so the assertions observe a complete, stable log.
|
||||
var toolsListProcessed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
McpServerBuilder
|
||||
.WithMessageFilters(filters =>
|
||||
{
|
||||
filters.AddIncomingFilter((next) => async (context, cancellationToken) =>
|
||||
{
|
||||
var isToolsList = context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList };
|
||||
var logger = GetLogger(context.Services, "MessageFilter1");
|
||||
logger.LogInformation("MessageFilter1 before");
|
||||
await next(context, cancellationToken);
|
||||
logger.LogInformation("MessageFilter1 after");
|
||||
|
||||
if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification })
|
||||
if (isToolsList)
|
||||
{
|
||||
initializedNotificationProcessed.TrySetResult(true);
|
||||
logger.LogInformation("MessageFilter1 before");
|
||||
}
|
||||
|
||||
await next(context, cancellationToken);
|
||||
|
||||
if (isToolsList)
|
||||
{
|
||||
logger.LogInformation("MessageFilter1 after");
|
||||
toolsListProcessed.TrySetResult(true);
|
||||
}
|
||||
});
|
||||
|
||||
filters.AddIncomingFilter((next) => async (context, cancellationToken) =>
|
||||
{
|
||||
var isToolsList = context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList };
|
||||
var logger = GetLogger(context.Services, "MessageFilter2");
|
||||
logger.LogInformation("MessageFilter2 before");
|
||||
if (isToolsList)
|
||||
{
|
||||
logger.LogInformation("MessageFilter2 before");
|
||||
}
|
||||
|
||||
await next(context, cancellationToken);
|
||||
logger.LogInformation("MessageFilter2 after");
|
||||
|
||||
if (isToolsList)
|
||||
{
|
||||
logger.LogInformation("MessageFilter2 after");
|
||||
}
|
||||
});
|
||||
})
|
||||
.WithTools<TestTool>();
|
||||
|
|
@ -153,38 +164,23 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for the fire-and-forget initialized notification to flow through the filter pipeline
|
||||
// before snapshotting the log; otherwise the strict counts below can race the notification.
|
||||
await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
// Wait for the outermost filter to finish processing the tools/list request before
|
||||
// snapshotting the log; otherwise the assertions can race the still-in-flight "after" logs.
|
||||
await toolsListProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
|
||||
var logMessages = MockLoggerProvider.LogMessages
|
||||
.Where(m => m.Category.StartsWith("MessageFilter"))
|
||||
.Select(m => m.Message)
|
||||
.ToList();
|
||||
|
||||
// First filter registered is outermost
|
||||
// We should see this pattern for each message: MessageFilter1 before -> MessageFilter2 before -> MessageFilter2 after -> MessageFilter1 after
|
||||
int idx1Before = logMessages.IndexOf("MessageFilter1 before");
|
||||
int idx2Before = logMessages.IndexOf("MessageFilter2 before");
|
||||
int idx2After = logMessages.IndexOf("MessageFilter2 after");
|
||||
int idx1After = logMessages.IndexOf("MessageFilter1 after");
|
||||
|
||||
Assert.True(idx1Before >= 0);
|
||||
Assert.True(idx2Before >= 0);
|
||||
Assert.True(idx2After >= 0);
|
||||
Assert.True(idx1After >= 0);
|
||||
|
||||
// Verify ordering within a single request
|
||||
Assert.True(idx1Before < idx2Before);
|
||||
Assert.True(idx2Before < idx2After);
|
||||
Assert.True(idx2After < idx1After);
|
||||
|
||||
// Verify each filter ran exactly once per incoming message (initialize + notifications/initialized + tools/list).
|
||||
// Strict counts catch regressions where the incoming filter pipeline gets invoked more than once per message.
|
||||
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 before"));
|
||||
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 before"));
|
||||
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 after"));
|
||||
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 after"));
|
||||
// First filter registered is outermost. For the single gated tools/list request we expect the
|
||||
// strict nested order. Assert.Collection also catches any regression that invokes the incoming
|
||||
// filter pipeline more than once per message (which would add extra entries).
|
||||
Assert.Collection(logMessages,
|
||||
m => Assert.Equal("MessageFilter1 before", m),
|
||||
m => Assert.Equal("MessageFilter2 before", m),
|
||||
m => Assert.Equal("MessageFilter2 after", m),
|
||||
m => Assert.Equal("MessageFilter1 after", m));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -396,6 +392,11 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
// This test observes the legacy outgoing flow on the server side: the initialize response and
|
||||
// the server->client sampling/createMessage request. Under the draft protocol those are replaced
|
||||
// by server/discover and implicit MRTR (InputRequiredResult), which is covered by MrtrIntegrationTests.
|
||||
// Pin to the latest stable version to keep exercising the legacy server->client request path here.
|
||||
ProtocolVersion = LatestStableVersion,
|
||||
Capabilities = new() { Sampling = new() },
|
||||
Handlers = new()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -128,7 +128,14 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
[Fact]
|
||||
public async Task Can_Be_Notified_Of_Prompt_Changes()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
// Under the draft revision, list-changed notifications are delivered only over a
|
||||
// subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the
|
||||
// legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on.
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.LatestProtocolVersion,
|
||||
});
|
||||
|
||||
|
||||
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal(8, prompts.Count);
|
||||
|
|
|
|||
|
|
@ -162,7 +162,14 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
[Fact]
|
||||
public async Task Can_Be_Notified_Of_Resource_Changes()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
// Under the draft revision, list-changed notifications are delivered only over a
|
||||
// subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the
|
||||
// legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on.
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.LatestProtocolVersion,
|
||||
});
|
||||
|
||||
|
||||
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal(7, resources.Count);
|
||||
|
|
|
|||
|
|
@ -188,7 +188,13 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
[Fact]
|
||||
public async Task Can_Be_Notified_Of_Tool_Changes()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
// Under the draft revision, list-changed notifications are delivered only over a
|
||||
// subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the
|
||||
// legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on.
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.LatestProtocolVersion,
|
||||
});
|
||||
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal(19, tools.Count);
|
||||
|
|
|
|||
|
|
@ -58,7 +58,11 @@ public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHe
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the given URI does NOT match the template.
|
||||
/// Asserts that the given URI does NOT match the template. Uses the default client, which
|
||||
/// negotiates the draft protocol revision, so the unknown-resource response carries the
|
||||
/// standard JSON-RPC <see cref="McpErrorCode.InvalidParams"/> (-32602). The version-gated
|
||||
/// legacy mapping to <see cref="McpErrorCode.ResourceNotFound"/> (-32002) is covered by
|
||||
/// <see cref="ResourceNotFound_ErrorCode_IsVersionGated"/>.
|
||||
/// </summary>
|
||||
private async Task AssertNoMatchAsync(
|
||||
string uriTemplate,
|
||||
|
|
@ -71,7 +75,7 @@ public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHe
|
|||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
|
||||
await client.ReadResourceAsync(uri, null, TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(McpErrorCode.ResourceNotFound, ex.ErrorCode);
|
||||
Assert.Equal(McpErrorCode.InvalidParams, ex.ErrorCode);
|
||||
}
|
||||
|
||||
// Unknown-resource-URI responses are version-gated: older clients keep the legacy
|
||||
|
|
@ -79,7 +83,7 @@ public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHe
|
|||
// moves to the standard JSON-RPC code see -32602 (McpErrorCode.InvalidParams).
|
||||
[Theory]
|
||||
[InlineData("2025-11-25", McpErrorCode.ResourceNotFound)]
|
||||
[InlineData("DRAFT-2026-v1", McpErrorCode.InvalidParams)]
|
||||
[InlineData("2026-07-28", McpErrorCode.InvalidParams)]
|
||||
public async Task ResourceNotFound_ErrorCode_IsVersionGated(string serverProtocolVersion, McpErrorCode expectedCode)
|
||||
{
|
||||
var resource = McpServerResource.Create(
|
||||
|
|
@ -130,7 +134,9 @@ public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHe
|
|||
|
||||
// Literal template braces in URI should not match (template literal is not a valid URI)
|
||||
var mcpEx = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", null, TestContext.Current.CancellationToken));
|
||||
Assert.Equal(McpErrorCode.ResourceNotFound, mcpEx.ErrorCode);
|
||||
// Draft maps an unmatched resource URI to InvalidParams (-32602); the legacy -32002 ResourceNotFound
|
||||
// mapping is covered by the version-gated ResourceNotFound_ErrorCode_IsVersionGated theory.
|
||||
Assert.Equal(McpErrorCode.InvalidParams, mcpEx.ErrorCode);
|
||||
Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,10 +91,13 @@ public class DiagnosticTests
|
|||
Assert.Equal(clientListToolsCall.SpanId, serverListToolsCall.ParentSpanId);
|
||||
Assert.Equal(clientListToolsCall.TraceId, serverListToolsCall.TraceId);
|
||||
|
||||
// Validate that the client trace context encoded to request.params._meta[traceparent]
|
||||
// Validate that the client trace context encoded to request.params._meta[traceparent].
|
||||
// Under the draft revision _meta also carries the per-request envelope (protocolVersion,
|
||||
// clientInfo, clientCapabilities), so assert on the traceparent property specifically
|
||||
// rather than the entire _meta object.
|
||||
using var listToolsJson = JsonDocument.Parse(clientToServerLog.First(s => s.Contains("\"method\":\"tools/list\"")));
|
||||
var metaJson = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetRawText();
|
||||
Assert.Equal($$"""{"traceparent":"00-{{clientListToolsCall.TraceId}}-{{clientListToolsCall.SpanId}}-01"}""", metaJson);
|
||||
var traceparent = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetProperty("traceparent").GetString();
|
||||
Assert.Equal($"00-{clientListToolsCall.TraceId}-{clientListToolsCall.SpanId}-01", traceparent);
|
||||
|
||||
// Validate that mcp.session.id is set on both client and server activities and that
|
||||
// all client activities share one session ID while all server activities share another.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ModelContextProtocol.Tests</RootNamespace>
|
||||
<!-- https://github.com/dotnet/sdk/issues/51060 -->
|
||||
<NoWarn>$(NoWarn);NU1903;NU1902</NoWarn>
|
||||
<NoWarn>$(NoWarn);NU1903;NU1902;MCP9006</NoWarn>
|
||||
<!-- Define for conditional TimeProvider support in InMemoryMcpTaskStore -->
|
||||
<DefineConstants>$(DefineConstants);MCP_TEST_TIME_PROVIDER</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ public class CancellationTests : ClientServerTestBase
|
|||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
// Pin to a legacy protocol version so the client performs the initialize handshake
|
||||
// (the spec rule under test is "the initialize request MUST NOT be cancelled by clients").
|
||||
ProtocolVersion = "2025-11-25",
|
||||
InitializationTimeout = TimeSpan.FromMilliseconds(500),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Serialization tests for the request/result types introduced by the draft protocol revision (SEP-2575).
|
||||
/// </summary>
|
||||
public static class DiscoverProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public static void DiscoverRequestParams_SerializationRoundTrip_WithMeta()
|
||||
{
|
||||
var original = new DiscoverRequestParams
|
||||
{
|
||||
Meta = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = "2026-07-28",
|
||||
[MetaKeys.ClientInfo] = new JsonObject
|
||||
{
|
||||
["name"] = "test-client",
|
||||
["version"] = "1.0",
|
||||
},
|
||||
[MetaKeys.ClientCapabilities] = new JsonObject(),
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverRequestParams>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.NotNull(deserialized.Meta);
|
||||
Assert.Equal("2026-07-28", (string)deserialized.Meta[MetaKeys.ProtocolVersion]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_SerializationRoundTrip_PreservesAllProperties()
|
||||
{
|
||||
var original = new DiscoverResult
|
||||
{
|
||||
SupportedVersions = new List<string> { "2025-11-25", "2026-07-28" },
|
||||
Capabilities = new ServerCapabilities
|
||||
{
|
||||
Tools = new ToolsCapability { ListChanged = true },
|
||||
},
|
||||
ServerInfo = new Implementation { Name = "test-server", Version = "2.0" },
|
||||
Instructions = "Use this server for testing.",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(["2025-11-25", "2026-07-28"], deserialized.SupportedVersions);
|
||||
Assert.NotNull(deserialized.Capabilities.Tools);
|
||||
Assert.True(deserialized.Capabilities.Tools.ListChanged);
|
||||
Assert.Equal("test-server", deserialized.ServerInfo.Name);
|
||||
Assert.Equal("Use this server for testing.", deserialized.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_SerializationRoundTrip_WithMinimalProperties()
|
||||
{
|
||||
var original = new DiscoverResult
|
||||
{
|
||||
SupportedVersions = new List<string> { "2026-07-28" },
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "minimal-server", Version = "1.0" },
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Single(deserialized.SupportedVersions);
|
||||
Assert.Equal("2026-07-28", deserialized.SupportedVersions[0]);
|
||||
Assert.Null(deserialized.Instructions);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Targeted tests for the SEP-2549 caching hints (<c>ttlMs</c> and <c>cacheScope</c>) on
|
||||
/// <see cref="DiscoverResult"/>. Spec PR #2855 promotes both fields to required on the discover
|
||||
/// response. <see cref="DiscoverResult"/> has <c>required</c> CLR properties for
|
||||
/// <see cref="DiscoverResult.SupportedVersions"/>, <see cref="DiscoverResult.Capabilities"/>, and
|
||||
/// <see cref="DiscoverResult.ServerInfo"/>, which prevents reuse of the parameterized
|
||||
/// <see cref="CacheableResultTests"/> helper (it instantiates via reflection). This file covers the
|
||||
/// same property-shape assertions for <see cref="DiscoverResult"/>.
|
||||
/// </summary>
|
||||
public static class DiscoverResultCacheableTests
|
||||
{
|
||||
private static DiscoverResult NewDiscoverResult() => new()
|
||||
{
|
||||
SupportedVersions = ["2025-11-25", McpSession.DraftProtocolVersion],
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "test-server", Version = "1.0" },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_SerializesTtlMsAsIntegerMilliseconds()
|
||||
{
|
||||
var result = NewDiscoverResult();
|
||||
result.TimeToLive = TimeSpan.FromMilliseconds(300_000);
|
||||
result.CacheScope = CacheScope.Public;
|
||||
|
||||
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
Assert.True(node.ContainsKey("ttlMs"));
|
||||
Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind());
|
||||
Assert.Equal(300_000, node["ttlMs"]!.GetValue<long>());
|
||||
Assert.Equal("public", node["cacheScope"]!.GetValue<string>());
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive);
|
||||
Assert.Equal(CacheScope.Public, deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_PrivateScope_RoundTrips()
|
||||
{
|
||||
var result = NewDiscoverResult();
|
||||
result.TimeToLive = TimeSpan.Zero;
|
||||
result.CacheScope = CacheScope.Private;
|
||||
|
||||
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
Assert.True(node.ContainsKey("ttlMs"));
|
||||
Assert.Equal(0, node["ttlMs"]!.GetValue<long>());
|
||||
Assert.Equal("private", node["cacheScope"]!.GetValue<string>());
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive);
|
||||
Assert.Equal(CacheScope.Private, deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_OmitsCachingHints_WhenUnset()
|
||||
{
|
||||
var result = NewDiscoverResult();
|
||||
|
||||
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
// Backward compatibility: servers that do not set the hints must not emit them.
|
||||
Assert.False(node.ContainsKey("ttlMs"));
|
||||
Assert.False(node.ContainsKey("cacheScope"));
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Null(deserialized.TimeToLive);
|
||||
Assert.Null(deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_DeserializesMissingHints_AsNull()
|
||||
{
|
||||
// A response from a pre-PR-#2855 server may omit both fields. Deserialization must succeed
|
||||
// and surface them as null so callers can apply their own defaults.
|
||||
string json =
|
||||
"""
|
||||
{
|
||||
"supportedVersions": ["2025-11-25"],
|
||||
"capabilities": {},
|
||||
"serverInfo": {"name": "x", "version": "1"}
|
||||
}
|
||||
""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Null(deserialized.TimeToLive);
|
||||
Assert.Null(deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_DeserializesUnknownCacheScope_AsNull()
|
||||
{
|
||||
// A future or unknown cacheScope string must not break deserialization of the entire result.
|
||||
string json =
|
||||
"""
|
||||
{
|
||||
"supportedVersions": ["2025-11-25"],
|
||||
"capabilities": {},
|
||||
"serverInfo": {"name": "x", "version": "1"},
|
||||
"cacheScope": "shared"
|
||||
}
|
||||
""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Null(deserialized.CacheScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void DiscoverResult_ImplementsICacheableResult()
|
||||
{
|
||||
// Compile-time assertion that DiscoverResult participates in the shared cacheability surface
|
||||
// alongside the list/read result types.
|
||||
Assert.IsAssignableFrom<ICacheableResult>(NewDiscoverResult());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Serialization tests for the error data payloads introduced by the draft protocol revision (SEP-2575).
|
||||
/// </summary>
|
||||
public static class DraftErrorDataTests
|
||||
{
|
||||
[Fact]
|
||||
public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_PreservesAllProperties()
|
||||
{
|
||||
var original = new UnsupportedProtocolVersionErrorData
|
||||
{
|
||||
Supported = new List<string> { "2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25" },
|
||||
Requested = "2026-07-28",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<UnsupportedProtocolVersionErrorData>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(4, deserialized.Supported.Count);
|
||||
Assert.Contains("2025-11-25", deserialized.Supported);
|
||||
Assert.Equal("2026-07-28", deserialized.Requested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void MissingRequiredClientCapabilityErrorData_SerializationRoundTrip_PreservesAllProperties()
|
||||
{
|
||||
var original = new MissingRequiredClientCapabilityErrorData
|
||||
{
|
||||
RequiredCapabilities = new ClientCapabilities
|
||||
{
|
||||
Sampling = new SamplingCapability(),
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<MissingRequiredClientCapabilityErrorData>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.NotNull(deserialized.RequiredCapabilities.Sampling);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void UnsupportedProtocolVersionException_ExposesRequestedAndSupported()
|
||||
{
|
||||
var ex = new UnsupportedProtocolVersionException("2099-12-31", ["2025-11-25", "2025-06-18"]);
|
||||
|
||||
Assert.Equal(McpErrorCode.UnsupportedProtocolVersion, ex.ErrorCode);
|
||||
Assert.Equal("2099-12-31", ex.Requested);
|
||||
Assert.Equal(2, ex.Supported.Count);
|
||||
Assert.Contains("2025-11-25", ex.Supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void MissingRequiredClientCapabilityException_ExposesRequiredCapabilities()
|
||||
{
|
||||
var caps = new ClientCapabilities { Roots = new RootsCapability() };
|
||||
var ex = new MissingRequiredClientCapabilityException(caps);
|
||||
|
||||
Assert.Equal(McpErrorCode.MissingRequiredClientCapability, ex.ErrorCode);
|
||||
Assert.Same(caps, ex.RequiredCapabilities);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue