Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)
This commit is contained in:
parent
3368bb7349
commit
6c323e783b
11 changed files with 5704 additions and 712 deletions
|
|
@ -9,15 +9,15 @@ namespace ModelContextProtocol.Tests.Server;
|
|||
/// Verifies that the server-to-client request methods (<see cref="McpServer.ElicitAsync(ElicitRequestParams, CancellationToken)"/>,
|
||||
/// <see cref="McpServer.SampleAsync(CreateMessageRequestParams, CancellationToken)"/>,
|
||||
/// <see cref="McpServer.RequestRootsAsync"/>) keep working when the negotiated protocol revision is
|
||||
/// <c>DRAFT-2026-v1</c> on a stateful session - for example, stdio.
|
||||
/// <c>2026-07-28</c> on a stateful session - for example, stdio.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Under <c>DRAFT-2026-v1</c> the spec removes the corresponding server-to-client request methods, but
|
||||
/// Under <c>2026-07-28</c> the spec removes the corresponding server-to-client request methods, but
|
||||
/// the SDK only fails fast in stateless mode (where the existing <c>ThrowIf*Unsupported</c> guards already
|
||||
/// throw "X is not supported in stateless mode" because <see cref="McpServer.ClientCapabilities"/> is
|
||||
/// <see langword="null"/>). Stdio is implicitly stateful - one <see cref="McpServer"/> per process - so the
|
||||
/// legacy <c>elicitation/create</c> / <c>sampling/createMessage</c> / <c>roots/list</c> flow still works.
|
||||
/// A future PR is expected to force <c>DRAFT-2026-v1</c> Streamable HTTP servers to stateless mode, at which
|
||||
/// A future PR is expected to force <c>2026-07-28</c> Streamable HTTP servers to stateless mode, at which
|
||||
/// point those configurations will start throwing through the existing stateless guard.
|
||||
/// </remarks>
|
||||
public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
|
||||
|
|
@ -31,7 +31,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
|
|||
{
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.ProtocolVersion = "DRAFT-2026-v1";
|
||||
options.ProtocolVersion = "2026-07-28";
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([
|
||||
|
|
@ -47,7 +47,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
|
|||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = "DRAFT-2026-v1",
|
||||
ProtocolVersion = "2026-07-28",
|
||||
Capabilities = new ClientCapabilities
|
||||
{
|
||||
Elicitation = new ElicitationCapability(),
|
||||
|
|
@ -69,7 +69,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
|
|||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = "DRAFT-2026-v1",
|
||||
ProtocolVersion = "2026-07-28",
|
||||
Capabilities = new ClientCapabilities
|
||||
{
|
||||
Sampling = new SamplingCapability(),
|
||||
|
|
@ -96,7 +96,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
|
|||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = "DRAFT-2026-v1",
|
||||
ProtocolVersion = "2026-07-28",
|
||||
Capabilities = new ClientCapabilities
|
||||
{
|
||||
Roots = new RootsCapability(),
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public class MrtrHandlerLifecycleTests : 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);
|
||||
});
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
StartServer();
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
// Cancel the token during the callback. The retry loop will throw
|
||||
|
|
@ -219,7 +219,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
StartServer();
|
||||
var elicitHandlerCalled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = async (request, ct) =>
|
||||
{
|
||||
// Signal that the MRTR round trip reached the client, then block indefinitely.
|
||||
|
|
@ -250,6 +250,29 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
|
||||
// The client call should fail (server disposed mid-MRTR).
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () => await callTask);
|
||||
|
||||
// Disposing the server while a continuation is suspended should log the cancellation of the
|
||||
// pending MRTR continuation once at Debug level (this is the only path that reaches the
|
||||
// continuation-cancellation log now that HTTP draft is always sessionless). Poll for the
|
||||
// async cancellation to propagate through the handler task.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
while (!MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation"))
|
||||
&& DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
var mrtrCancelledLog = MockLoggerProvider.LogMessages
|
||||
.Where(m => m.Message.Contains("pending MRTR continuation"))
|
||||
.ToList();
|
||||
var log = Assert.Single(mrtrCancelledLog);
|
||||
Assert.Equal(LogLevel.Debug, log.LogLevel);
|
||||
Assert.Contains("1", log.Message);
|
||||
|
||||
// The handler's OperationCanceledException must be silently observed during disposal, not
|
||||
// logged as an error.
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m =>
|
||||
m.LogLevel >= LogLevel.Error && m.Message.Contains("cancellation-test-tool"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -263,7 +286,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
// (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts.
|
||||
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" });
|
||||
|
||||
|
|
@ -301,7 +324,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
StartServer();
|
||||
|
||||
int elicitationCount = 0;
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
Interlocked.Increment(ref elicitationCount);
|
||||
|
|
@ -345,7 +368,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
StartServer();
|
||||
bool handlerCompleted = false;
|
||||
|
||||
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" });
|
||||
|
||||
|
|
@ -387,7 +410,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
// (after resuming from ElicitAsync), the error is logged at Error level.
|
||||
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" });
|
||||
|
||||
|
|
@ -417,7 +440,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
|
|||
// not an error. It should not be logged via ToolCallError at Error level.
|
||||
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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public class MrtrInputRequiredExceptionTests : ClientServerTestBase
|
|||
{
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.ProtocolVersion = "DRAFT-2026-v1";
|
||||
options.ProtocolVersion = "2026-07-28";
|
||||
_messageTracker.AddFilters(options.Filters.Message);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
|
|||
{
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.ProtocolVersion = "DRAFT-2026-v1";
|
||||
options.ProtocolVersion = "2026-07-28";
|
||||
_messageTracker.AddFilters(options.Filters.Message);
|
||||
});
|
||||
|
||||
|
|
@ -73,14 +73,14 @@ public class MrtrMessageFilterTests : ClientServerTestBase
|
|||
// When both sides are on the experimental protocol, the server should use MRTR
|
||||
// (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests.
|
||||
StartServer();
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
|
||||
};
|
||||
|
||||
await using var client = await CreateMcpClientForServer(clientOptions);
|
||||
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
|
||||
|
||||
var result = await client.CallToolAsync("elicit-tool",
|
||||
new Dictionary<string, object?> { ["message"] = "test" },
|
||||
|
|
@ -95,7 +95,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
|
|||
public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire()
|
||||
{
|
||||
StartServer();
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
|
||||
{
|
||||
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
|
||||
|
|
@ -107,7 +107,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
|
|||
};
|
||||
|
||||
await using var client = await CreateMcpClientForServer(clientOptions);
|
||||
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
|
||||
|
||||
var result = await client.CallToolAsync("sample-tool",
|
||||
new Dictionary<string, object?> { ["prompt"] = "test" },
|
||||
|
|
@ -126,7 +126,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
|
|||
var sawIncompleteResult = false;
|
||||
|
||||
StartServer();
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
// If we reach this handler, it means the client received an InputRequiredResult
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ namespace ModelContextProtocol.Tests.Server;
|
|||
|
||||
/// <summary>
|
||||
/// Tests for the legacy MRTR backcompat resolver in <c>McpServerImpl.InvokeWithInputRequiredResultHandlingAsync</c>.
|
||||
/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful -
|
||||
/// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful -
|
||||
/// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler
|
||||
/// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks
|
||||
/// a non-draft version during initialize negotiation.
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public class MrtrSessionLimitTests : ClientServerTestBase
|
|||
{
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.ProtocolVersion = "DRAFT-2026-v1";
|
||||
options.ProtocolVersion = "2026-07-28";
|
||||
_messageTracker.AddFilters(options.Filters.Message);
|
||||
|
||||
// Outgoing filter: detect InputRequiredResult responses and track per session.
|
||||
|
|
@ -131,7 +131,7 @@ public class MrtrSessionLimitTests : ClientServerTestBase
|
|||
// Verify that an outgoing message filter can observe InputRequiredResult responses
|
||||
// and track the pending MRTR flow count per session using context.Server.SessionId.
|
||||
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" });
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ public class MrtrSessionLimitTests : ClientServerTestBase
|
|||
_maxFlowsPerSession = 0;
|
||||
|
||||
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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.ComponentModel;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the server establishes its negotiated protocol version exactly once per stateful session: the
|
||||
/// initial <see langword="null"/>-to-value transition is allowed and re-sending the same version is an
|
||||
/// idempotent no-op, but a request that switches to a different (even if otherwise supported) version is
|
||||
/// rejected with <see cref="McpErrorCode.InvalidRequest"/>. The session is driven over a raw stream
|
||||
/// transport (the stdio-shaped, stateful path) so the per-request <c>_meta</c> protocol version is fully
|
||||
/// controlled - the SDK's own client normalizes it on every outgoing request, so only a misbehaving peer
|
||||
/// can trigger a mid-session change.
|
||||
/// </summary>
|
||||
public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposable
|
||||
{
|
||||
private readonly Pipe _clientToServer = new();
|
||||
private readonly Pipe _serverToClient = new();
|
||||
private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly Task _serverTask;
|
||||
private readonly StreamWriter _writer;
|
||||
private readonly StreamReader _reader;
|
||||
|
||||
public NegotiatedProtocolVersionTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper)
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddLogging();
|
||||
serviceCollection.AddSingleton<ILoggerProvider>(XunitLoggerProvider);
|
||||
serviceCollection
|
||||
.AddMcpServer()
|
||||
.WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream())
|
||||
.WithTools<EchoTools>();
|
||||
|
||||
_services = serviceCollection.BuildServiceProvider(validateScopes: true);
|
||||
var server = _services.GetRequiredService<McpServer>();
|
||||
_serverTask = server.RunAsync(_cts.Token);
|
||||
|
||||
_writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true };
|
||||
_reader = new StreamReader(_serverToClient.Reader.AsStream());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterChange()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// The first request establishes the draft version for the stateful session (null -> draft).
|
||||
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 1, McpSession.DraftProtocolVersion, ct));
|
||||
|
||||
// Re-sending the same version is an idempotent no-op, not an error.
|
||||
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 2, McpSession.DraftProtocolVersion, ct));
|
||||
|
||||
// Switching to a different (still-supported) version mid-session is rejected.
|
||||
var error = Assert.IsType<JsonRpcError>(await RoundTripAsync(id: 3, McpSession.LatestProtocolVersion, ct));
|
||||
Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code);
|
||||
Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// The rejected request must not have mutated the negotiated version: the original draft version still works.
|
||||
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 4, McpSession.DraftProtocolVersion, ct));
|
||||
}
|
||||
|
||||
private async Task<JsonRpcMessage> RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
// tools/list is available under both the legacy and draft revisions (unlike ping/initialize,
|
||||
// which the draft revision removed), so it exercises the version guard rather than the
|
||||
// per-method availability gate.
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(id),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = protocolVersion,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
|
||||
#if NET
|
||||
await _writer.WriteLineAsync(json.AsMemory(), cancellationToken);
|
||||
#else
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await _writer.WriteLineAsync(json);
|
||||
#endif
|
||||
|
||||
while (true)
|
||||
{
|
||||
#if NET
|
||||
string? line = await _reader.ReadLineAsync(cancellationToken)
|
||||
.AsTask()
|
||||
.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
|
||||
#else
|
||||
string? line = await _reader.ReadLineAsync()
|
||||
.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
|
||||
#endif
|
||||
|
||||
if (line is null)
|
||||
{
|
||||
throw new InvalidOperationException("Server stream closed before responding.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var message = (JsonRpcMessage)JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)))!;
|
||||
|
||||
// Ignore anything that isn't the response to the request we just sent (e.g. notifications).
|
||||
if (message is JsonRpcMessageWithId withId && withId.Id.Equals(request.Id))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_clientToServer.Writer.Complete();
|
||||
_serverToClient.Writer.Complete();
|
||||
|
||||
try
|
||||
{
|
||||
await _serverTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
await _services.DisposeAsync();
|
||||
_cts.Dispose();
|
||||
Dispose();
|
||||
}
|
||||
|
||||
[McpServerToolType]
|
||||
private sealed class EchoTools
|
||||
{
|
||||
[McpServerTool, Description("Echoes the input back to the caller.")]
|
||||
public static string Echo([Description("The message to echo.")] string message) => message;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the built-in <c>ping</c> handler is gated by protocol version.
|
||||
/// SEP-2575 (the draft 2026-07-28 revision) removes <c>ping</c>; servers must
|
||||
/// respond with <c>-32601 MethodNotFound</c>. Legacy protocol versions still
|
||||
/// support <c>ping</c> per the spec.
|
||||
/// </summary>
|
||||
public sealed class PingProtocolGatingTests : ClientServerTestBase
|
||||
{
|
||||
public PingProtocolGatingTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper, startServer: false)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_OnDraftSession_ReturnsMethodNotFound()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
});
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
|
||||
await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_OnLegacySession_StillSucceeds()
|
||||
{
|
||||
// Default server config; client pinned to 2025-11-25.
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = "2025-11-25",
|
||||
});
|
||||
|
||||
var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
#if !NET472
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format conformance tests for <see cref="McpServer"/> driven directly against the underlying
|
||||
/// stream — without going through <see cref="ModelContextProtocol.Client.McpClient"/>. This exercises the
|
||||
/// SEP-2575 (sessionless / no-initialize) and SEP-2567 (server/discover) flows by hand-crafting JSON-RPC
|
||||
/// messages and asserting on the exact responses the server emits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tests use a paired <see cref="Pipe"/> the way <see cref="ClientServerTestBase"/> does, but instead
|
||||
/// of constructing an <c>McpClient</c> we read and write JSON-RPC envelopes directly. This is the closest
|
||||
/// approximation we have to a third-party / non-SDK client and is what conformance tooling will exercise.
|
||||
/// </remarks>
|
||||
public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable
|
||||
{
|
||||
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
|
||||
|
||||
private readonly Pipe _clientToServer = new();
|
||||
private readonly Pipe _serverToClient = new();
|
||||
private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
private readonly Task _serverTask;
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly StreamReader _reader;
|
||||
private readonly StreamWriter _writer;
|
||||
|
||||
public RawStreamConformanceTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(b => b.AddProvider(XunitLoggerProvider));
|
||||
services
|
||||
.AddMcpServer(options =>
|
||||
{
|
||||
options.ServerInfo = new Implementation { Name = "raw-conformance-server", Version = "1.0.0" };
|
||||
})
|
||||
.WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream())
|
||||
.WithTools([
|
||||
McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" }),
|
||||
]);
|
||||
|
||||
_services = services.BuildServiceProvider(validateScopes: true);
|
||||
var server = _services.GetRequiredService<McpServer>();
|
||||
_serverTask = server.RunAsync(_cts.Token);
|
||||
|
||||
_writer = new StreamWriter(_clientToServer.Writer.AsStream(), new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" };
|
||||
_reader = new StreamReader(_serverToClient.Reader.AsStream(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_clientToServer.Writer.Complete();
|
||||
_serverToClient.Writer.Complete();
|
||||
try { await _serverTask; } catch { /* expected on cancellation */ }
|
||||
await _services.DisposeAsync();
|
||||
_cts.Dispose();
|
||||
Dispose();
|
||||
}
|
||||
|
||||
private async Task SendAsync(string json) => await _writer.WriteLineAsync(json);
|
||||
|
||||
private async Task<JsonNode> ReadAsync()
|
||||
{
|
||||
var line = await _reader.ReadLineAsync(_cts.Token).ConfigureAwait(false);
|
||||
Assert.NotNull(line);
|
||||
return JsonNode.Parse(line!)!;
|
||||
}
|
||||
|
||||
private static string DraftMetaFragment(string protocolVersion = DraftVersion) =>
|
||||
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
|
||||
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
|
||||
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
|
||||
|
||||
[Fact]
|
||||
public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft()
|
||||
{
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}");
|
||||
|
||||
var response = await ReadAsync();
|
||||
Assert.Equal("2.0", response["jsonrpc"]!.GetValue<string>());
|
||||
Assert.Equal(1, response["id"]!.GetValue<int>());
|
||||
|
||||
var result = response["result"];
|
||||
Assert.NotNull(result);
|
||||
|
||||
var supportedVersions = result!["supportedVersions"]!.AsArray()
|
||||
.Select(n => n!.GetValue<string>())
|
||||
.ToList();
|
||||
Assert.Contains(DraftVersion, supportedVersions);
|
||||
|
||||
// Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575.
|
||||
Assert.NotNull(result["capabilities"]);
|
||||
Assert.NotNull(result["serverInfo"]);
|
||||
Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue<string>());
|
||||
|
||||
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
|
||||
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
|
||||
Assert.Equal(JsonValueKind.Number, result["ttlMs"]!.GetValueKind());
|
||||
Assert.Equal(0, result["ttlMs"]!.GetValue<long>());
|
||||
Assert.Equal("private", result["cacheScope"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided()
|
||||
{
|
||||
// Spec: under SEP-2575 the client may skip server/discover and go straight to a normal RPC, as long
|
||||
// as every request carries the full _meta envelope with protocolVersion, clientInfo and capabilities.
|
||||
await SendAsync(
|
||||
@"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hello""}," +
|
||||
DraftMetaFragment() + "}}");
|
||||
|
||||
var response = await ReadAsync();
|
||||
Assert.Equal(42, response["id"]!.GetValue<int>());
|
||||
var result = response["result"];
|
||||
Assert.NotNull(result);
|
||||
var content = result!["content"]!.AsArray();
|
||||
Assert.Single(content);
|
||||
Assert.Equal("echo:hello", content[0]!["text"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftRequest_WithUnsupportedProtocolVersion_ReturnsMinus32004WithSupported()
|
||||
{
|
||||
// Server should respond with UnsupportedProtocolVersionError (-32004) and a data.supported[] list.
|
||||
await SendAsync(
|
||||
@"{""jsonrpc"":""2.0"",""id"":7,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," +
|
||||
DraftMetaFragment("9999-99-99") + "}}");
|
||||
|
||||
var response = await ReadAsync();
|
||||
Assert.Equal(7, response["id"]!.GetValue<int>());
|
||||
var error = response["error"];
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error!["code"]!.GetValue<int>());
|
||||
|
||||
var data = error["data"];
|
||||
Assert.NotNull(data);
|
||||
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
|
||||
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Contains(DraftVersion, supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyInitialize_StillWorks_OnDraftDefaultServer()
|
||||
{
|
||||
// Dual-era: a draft-default server (ProtocolVersion = DraftVersion in McpServerOptions) must still
|
||||
// accept the legacy initialize handshake from clients that don't speak the new protocol.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}");
|
||||
|
||||
var response = await ReadAsync();
|
||||
Assert.Equal(1, response["id"]!.GetValue<int>());
|
||||
var result = response["result"];
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed()
|
||||
{
|
||||
// Dual-era servers must accept draft and legacy traffic on the same connection. The exact mix below
|
||||
// is what a permissive client running against an unknown server would emit while probing.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}");
|
||||
var discover = await ReadAsync();
|
||||
Assert.NotNull(discover["result"]);
|
||||
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}");
|
||||
var init = await ReadAsync();
|
||||
Assert.NotNull(init["result"]);
|
||||
Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue<string>());
|
||||
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""method"":""notifications/initialized"",""params"":{}}");
|
||||
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""after-init""}}}");
|
||||
var call = await ReadAsync();
|
||||
Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for the SEP-2575 <c>subscriptions/listen</c> list-changed delivery over an
|
||||
/// in-memory stream transport (the stdio-shaped path exercised by <see cref="ClientServerTestBase"/>).
|
||||
/// Validates that a draft client receives only the change notifications it subscribed to, each tagged
|
||||
/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast.
|
||||
/// </summary>
|
||||
public class SubscriptionsListenTests : ClientServerTestBase
|
||||
{
|
||||
public SubscriptionsListenTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.WithTools<ListenTools>();
|
||||
mcpServerBuilder.WithPrompts<ListenPrompts>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_ToolsListChangedSubscription_DeliversTaggedNotification_AndWithholdsUnsubscribed()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var promptsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
|
||||
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
|
||||
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
|
||||
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
|
||||
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
|
||||
await using var promptsReg = client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification,
|
||||
(notification, _) => { promptsChannel.Writer.TryWrite(notification); return default; });
|
||||
|
||||
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var listenTask = SendSubscriptionsListenAsync(client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token);
|
||||
|
||||
// SEP-2575: the acknowledgement is always sent first, tagged with the subscription id.
|
||||
var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
var subscriptionId = GetSubscriptionId(ack);
|
||||
Assert.NotNull(subscriptionId);
|
||||
|
||||
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
var serverTools = serverOptions.ToolCollection!;
|
||||
var serverPrompts = serverOptions.PromptCollection!;
|
||||
|
||||
// A prompt change must never reach this client: it only subscribed to tool list changes. Because the
|
||||
// fan-out skips it without sending anything, the prompts channel stays empty for the rest of the test.
|
||||
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "AddedPrompt")] () => "added"));
|
||||
|
||||
// A tool change must arrive on the subscription stream, tagged with the same subscription id as the ack.
|
||||
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
|
||||
var toolsNotification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(subscriptionId, GetSubscriptionId(toolsNotification));
|
||||
|
||||
// Tear down the open subscription request before the client is disposed.
|
||||
await CancelSubscriptionAsync(listenCts, listenTask);
|
||||
|
||||
// The prompt change fired before the (delivered) tool change, and notifications arrive in order
|
||||
// on the subscription stream, so any prompt notification would already be buffered by now.
|
||||
// Complete the writer and assert the channel drains empty - i.e. nothing was ever delivered,
|
||||
// not merely "nothing is buffered at this instant". WaitToReadAsync returns false only when the
|
||||
// channel is both empty and completed; a buffered erroneous notification would make it true.
|
||||
promptsChannel.Writer.Complete();
|
||||
Assert.False(await promptsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_WithoutSubscription_DoesNotBroadcastListChanged()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
|
||||
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
|
||||
|
||||
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
|
||||
|
||||
// The change notification must not be broadcast to a draft client that never opened a
|
||||
// subscriptions/listen stream. The list-changed handler runs synchronously during Add (before
|
||||
// the ListTools round-trip below completes), so any erroneous broadcast would already be
|
||||
// buffered once the round-trip returns.
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Complete the writer and assert the channel drains empty rather than just checking the current
|
||||
// buffer: WaitToReadAsync returns false only when the channel is both empty and completed.
|
||||
toolsChannel.Writer.Complete();
|
||||
Assert.False(await toolsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpSession.LatestProtocolVersion,
|
||||
});
|
||||
|
||||
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
|
||||
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
|
||||
|
||||
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
|
||||
|
||||
// Legacy sessions keep the session-wide broadcast and the notification carries no subscription id.
|
||||
var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Null(GetSubscriptionId(notification));
|
||||
}
|
||||
|
||||
private static Task SendSubscriptionsListenAsync(McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Method = RequestMethods.SubscriptionsListen,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsListenRequestParams { Notifications = notifications },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
return client.SendRequestAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task CancelSubscriptionAsync(CancellationTokenSource listenCts, Task listenTask)
|
||||
{
|
||||
await listenCts.CancelAsync();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => listenTask);
|
||||
}
|
||||
|
||||
private static string? GetSubscriptionId(JsonRpcNotification notification)
|
||||
=> ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString();
|
||||
|
||||
[McpServerToolType]
|
||||
private sealed class ListenTools
|
||||
{
|
||||
[McpServerTool, Description("Echoes the input back to the caller.")]
|
||||
public static string Echo([Description("The message to echo.")] string message) => message;
|
||||
}
|
||||
|
||||
[McpServerPromptType]
|
||||
private sealed class ListenPrompts
|
||||
{
|
||||
[McpServerPrompt, Description("A simple prompt.")]
|
||||
public static ChatMessage Simple() => new(ChatRole.User, "hello");
|
||||
}
|
||||
}
|
||||
189
tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs
Normal file
189
tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
#pragma warning disable MCPEXP001
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the SEP-2663 Tasks extension is gated to the draft protocol revision on both the
|
||||
/// client and the server. Explicit task operations throw on a legacy session; best-effort task
|
||||
/// augmentation silently downgrades to a direct result so that legacy peers never see a task.
|
||||
/// </summary>
|
||||
public class TaskDraftGatingTests : ClientServerTestBase
|
||||
{
|
||||
private const string LatestStableVersion = "2025-11-25";
|
||||
|
||||
private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities";
|
||||
private const string ExtensionsKey = "extensions";
|
||||
|
||||
public TaskDraftGatingTests(ITestOutputHelper outputHelper)
|
||||
: base(outputHelper)
|
||||
{
|
||||
#if !NET
|
||||
Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587");
|
||||
#endif
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = new InMemoryMcpTaskStore
|
||||
{
|
||||
DefaultPollIntervalMs = 50,
|
||||
};
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([McpServerTool.Create(
|
||||
async (string input, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(50, ct);
|
||||
return $"Processed: {input}";
|
||||
},
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "test-tool",
|
||||
Description = "A test tool"
|
||||
})]);
|
||||
}
|
||||
|
||||
private static IDictionary<string, JsonElement> CreateArguments(string key, string value)
|
||||
{
|
||||
return new Dictionary<string, JsonElement>
|
||||
{
|
||||
[key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone()
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject CreateForgedTaskOptInMeta() =>
|
||||
new()
|
||||
{
|
||||
[ClientCapabilitiesMetaKey] = new JsonObject
|
||||
{
|
||||
[ExtensionsKey] = new JsonObject
|
||||
{
|
||||
[McpExtensions.Tasks] = new JsonObject(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_GetTaskAsync_ThrowsInvalidOperationException()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await client.GetTaskAsync("some-task-id", ct));
|
||||
|
||||
Assert.Contains("draft protocol revision", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_UpdateTaskAsync_ThrowsInvalidOperationException()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await client.UpdateTaskAsync(new UpdateTaskRequestParams { TaskId = "some-task-id" }, ct));
|
||||
|
||||
Assert.Contains("draft protocol revision", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_CancelTaskAsync_ThrowsInvalidOperationException()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await client.CancelTaskAsync("some-task-id", ct));
|
||||
|
||||
Assert.Contains("draft protocol revision", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolRawAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "legacy"),
|
||||
}, ct);
|
||||
|
||||
Assert.False(result.IsTask);
|
||||
Assert.NotNull(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy
|
||||
// request. The server must still refuse to create a task because the per-request protocol
|
||||
// version is not the draft revision.
|
||||
var result = await client.CallToolRawAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "forged"),
|
||||
Meta = CreateForgedTaskOptInMeta(),
|
||||
}, ct);
|
||||
|
||||
Assert.False(result.IsTask);
|
||||
Assert.NotNull(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Bypass the typed GetTaskAsync client guard by sending a raw tasks/get request. The server
|
||||
// gates tasks/* to the draft revision and must reject this legacy request with MethodNotFound.
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Method = RequestMethods.TasksGet,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new GetTaskRequestParams { TaskId = "some-task-id" },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
|
||||
await client.SendRequestAsync(request, ct));
|
||||
|
||||
Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftClient_CallToolRaw_CreatesTask()
|
||||
{
|
||||
// Sanity: the default client negotiates the draft revision, so the task flow still works.
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolRawAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "draft"),
|
||||
}, ct);
|
||||
|
||||
Assert.True(result.IsTask);
|
||||
Assert.NotNull(result.TaskCreated);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue