From 0093341781dc8c4a94de625aecbec3d476bfc91e Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 27 Jan 2026 20:45:38 -0800
Subject: [PATCH 001/320] Bump version to 0.8.0-preview.1 (#1181)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>
---
src/Directory.Build.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index f4287e5..c7d089c 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -5,7 +5,7 @@
https://modelcontextprotocol.github.io/csharp-sdk
https://github.com/modelcontextprotocol/csharp-sdk
git
- 0.7.0
+ 0.8.0
preview.1
ModelContextProtocol
© Anthropic and Contributors.
From 201d8d4819bcd56b447aaa8e4552a70357e6ca4b Mon Sep 17 00:00:00 2001
From: Ruyut
Date: Thu, 29 Jan 2026 00:53:32 +0800
Subject: [PATCH 002/320] Fix typo in elicitation.md (#1186)
---
docs/concepts/elicitation/elicitation.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md
index aa5cdcd..b4d73a8 100644
--- a/docs/concepts/elicitation/elicitation.md
+++ b/docs/concepts/elicitation/elicitation.md
@@ -98,7 +98,7 @@ Here's an example implementation of how a console application might handle elici
### URL Elicitation Required Error
-When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in is enabled disabling server-to-client requets), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried.
+When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in is enabled disabling server-to-client requests), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried.
#### Throwing UrlElicitationRequiredException on the Server
From 727d507dbdfffab8bf91c17e0121e4eb773ea8c4 Mon Sep 17 00:00:00 2001
From: Ruyut
Date: Thu, 29 Jan 2026 01:36:14 +0800
Subject: [PATCH 003/320] Fix typo in progress.md (#1189)
---
docs/concepts/progress/progress.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/concepts/progress/progress.md b/docs/concepts/progress/progress.md
index abb4ee6..003d65e 100644
--- a/docs/concepts/progress/progress.md
+++ b/docs/concepts/progress/progress.md
@@ -34,7 +34,7 @@ Note that servers aren't required to support progress tracking, so clients shoul
In the MCP C# SDK, clients can specify a `progressToken` in the request parameters when calling a tool method.
The client should also provide a notification handler to process "notifications/progress" notifications.
-There are two way to do this. The first is to register a notification handler using the method on the instance. A handler registered this way will receive all progress notifications sent by the server.
+There are two ways to do this. The first is to register a notification handler using the method on the instance. A handler registered this way will receive all progress notifications sent by the server.
```csharp
mcpClient.RegisterNotificationHandler(NotificationMethods.ProgressNotification,
From 269f39f5a128b2597ea4cea80d82952875c89460 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 28 Jan 2026 21:21:58 -0500
Subject: [PATCH 004/320] Bump actions/checkout from 6.0.1 to 6.0.2 (#1173)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stephen Toub
---
.github/workflows/ci-build-test.yml | 2 +-
.github/workflows/ci-code-coverage.yml | 2 +-
.github/workflows/copilot-setup-steps.yml | 2 +-
.github/workflows/docs.yml | 2 +-
.github/workflows/markdown-link-check.yml | 2 +-
.github/workflows/release.yml | 10 +++++-----
6 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml
index 838dce4..37ee6ab 100644
--- a/.github/workflows/ci-build-test.yml
+++ b/.github/workflows/ci-build-test.yml
@@ -36,7 +36,7 @@ jobs:
steps:
- name: 📥 Clone the repo
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml
index a3fd746..9ff5ed0 100644
--- a/.github/workflows/ci-code-coverage.yml
+++ b/.github/workflows/ci-code-coverage.yml
@@ -10,7 +10,7 @@ jobs:
publish-coverage:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
with:
diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
index cfcc142..6f3bb37 100644
--- a/.github/workflows/copilot-setup-steps.yml
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -11,7 +11,7 @@ jobs:
contents: read
steps:
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET SDK
uses: actions/setup-dotnet@v5.1.0
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 88bb2ca..51e6a5c 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: .NET Setup
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml
index 1616baf..69bc633 100644
--- a/.github/workflows/markdown-link-check.yml
+++ b/.github/workflows/markdown-link-check.yml
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Markup Link Checker (mlc)
uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b3acced..bddd6e8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -45,7 +45,7 @@ jobs:
steps:
- name: Clone the repo
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
@@ -73,7 +73,7 @@ jobs:
version_suffix_args: ${{ github.event_name != 'release' && format('--version-suffix "{0}"', inputs.version_suffix_override || format('ci.{0}', github.run_number)) || '' }}
steps:
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
@@ -103,7 +103,7 @@ jobs:
packages: write
steps:
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
@@ -138,7 +138,7 @@ jobs:
packages: write
steps:
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download build artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
@@ -158,7 +158,7 @@ jobs:
permissions: { }
steps:
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
From 84dcfbc38c5b03b0bf889ccd954dffd0e298aae3 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 28 Jan 2026 21:23:28 -0500
Subject: [PATCH 005/320] Bump the opentelemetry-testing group with 6 updates
(#1174)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stephen Toub
---
Directory.Packages.props | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b5f5990..42ecc18 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -77,12 +77,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
From e4692faeda632e464b85c62339d595d516cb9818 Mon Sep 17 00:00:00 2001
From: Stephen Toub
Date: Thu, 29 Jan 2026 10:36:54 -0500
Subject: [PATCH 006/320] Remove 10 second wait from docker tests (#1188)
---
.../EverythingSseServerFixture.cs | 27 +++++++++++++++++--
1 file changed, 25 insertions(+), 2 deletions(-)
diff --git a/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs b/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs
index 7a019c8..f12aff5 100644
--- a/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs
+++ b/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Net;
namespace ModelContextProtocol.Tests;
@@ -31,8 +32,30 @@ public class EverythingSseServerFixture : IAsyncDisposable
_ = Process.Start(processStartInfo)
?? throw new InvalidOperationException($"Could not start process for {processStartInfo.FileName} with '{processStartInfo.Arguments}'.");
- // Wait for the server to start
- await Task.Delay(10000);
+ // Poll until the server is ready (up to 30 seconds)
+ using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+ var endpoint = $"http://localhost:{_port}/sse";
+ var deadline = DateTime.UtcNow.AddSeconds(30);
+
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ using var response = await httpClient.GetAsync(endpoint, HttpCompletionOption.ResponseHeadersRead);
+ if (response.IsSuccessStatusCode || response.StatusCode is HttpStatusCode.MethodNotAllowed)
+ {
+ return;
+ }
+ }
+ catch (Exception e) when (e is HttpRequestException or OperationCanceledException)
+ {
+ // server not ready
+ }
+
+ await Task.Delay(100);
+ }
+
+ throw new InvalidOperationException($"Docker container failed to start within 30 seconds on port {_port}");
}
public async ValueTask DisposeAsync()
{
From 5b189aff0a7802a451b65dbcb5c11baa82fa5332 Mon Sep 17 00:00:00 2001
From: Stephen Toub
Date: Thu, 29 Jan 2026 10:42:03 -0500
Subject: [PATCH 007/320] Fix Session_TracksActivities test (#1200)
---
.../DiagnosticTests.cs | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs
index a8195c7..a3d9b4e 100644
--- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs
+++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs
@@ -32,6 +32,11 @@ public class DiagnosticTests
var tool = tools.First(t => t.Name == "DoubleValue");
await tool.InvokeAsync(new() { ["amount"] = 42 }, TestContext.Current.CancellationToken);
}, clientToServerLog);
+
+ // Wait for server-side activities to be exported. The server processes messages
+ // via fire-and-forget tasks, so activities may not be immediately available
+ // after the client operation completes.
+ await WaitForAsync(() => activities.Count(a => a.Kind == ActivityKind.Server) >= 4);
}
Assert.NotEmpty(activities);
@@ -97,6 +102,9 @@ public class DiagnosticTests
await client.CallToolAsync("Throw", cancellationToken: TestContext.Current.CancellationToken);
await Assert.ThrowsAsync(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken));
}, []);
+
+ // Wait for server-side activities to be exported.
+ await WaitForAsync(() => activities.Count(a => a.Kind == ActivityKind.Server) >= 4);
}
Assert.NotEmpty(activities);
@@ -164,6 +172,9 @@ public class DiagnosticTests
.First(t => t.Name == "DoubleValue");
await tool.InvokeAsync(new() { ["amount"] = 42 }, TestContext.Current.CancellationToken);
}, []);
+
+ // Wait for server-side activities to be exported.
+ await WaitForAsync(() => activities.Count(a => a.Kind == ActivityKind.Server) >= 3);
}
// The outer activity should have MCP-specific attributes added to it
@@ -215,6 +226,15 @@ public class DiagnosticTests
await serverTask;
}
+
+ private static async Task WaitForAsync(Func condition, int timeoutMs = 10_000)
+ {
+ using var cts = new CancellationTokenSource(timeoutMs);
+ while (!condition())
+ {
+ await Task.Delay(10, cts.Token);
+ }
+ }
}
public class LoggingStream : Stream
From ee8c485d413c21acda9fc5fcd4a8fd182a89db46 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 11:26:33 -0800
Subject: [PATCH 008/320] Add Trace-level logging for JSON-RPC payloads in
transports (#1191)
---
.../Client/SseClientSessionTransport.cs | 2 +
.../Client/StreamClientSessionTransport.cs | 2 +
.../StreamableHttpClientSessionTransport.cs | 2 +
.../Protocol/TransportBase.cs | 16 +++++
.../Server/StreamServerTransport.cs | 4 +-
.../Transport/StdioServerTransportTests.cs | 66 ++++++++++++++++++-
6 files changed, 90 insertions(+), 2 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
index 60950df..fa9ee97 100644
--- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
@@ -80,6 +80,8 @@ internal sealed partial class SseClientSessionTransport : TransportBase
messageId = messageWithId.Id.ToString();
}
+ LogTransportSendingMessageSensitive(message);
+
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint);
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false);
diff --git a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
index c896bd4..d582abe 100644
--- a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
@@ -105,6 +105,8 @@ internal class StreamClientSessionTransport : TransportBase
var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
+ LogTransportSendingMessageSensitive(Name, json);
+
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
try
{
diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
index 0175125..c33d353 100644
--- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
@@ -75,6 +75,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
$"Call {nameof(McpClient)}.{nameof(McpClient.ResumeSessionAsync)} to resume existing sessions.");
}
+ LogTransportSendingMessageSensitive(message);
+
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
cancellationToken = sendCts.Token;
diff --git a/src/ModelContextProtocol.Core/Protocol/TransportBase.cs b/src/ModelContextProtocol.Core/Protocol/TransportBase.cs
index 97897b5..e3e8e8c 100644
--- a/src/ModelContextProtocol.Core/Protocol/TransportBase.cs
+++ b/src/ModelContextProtocol.Core/Protocol/TransportBase.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Diagnostics;
+using System.Text.Json;
using System.Threading.Channels;
namespace ModelContextProtocol.Protocol;
@@ -166,6 +167,21 @@ public abstract partial class TransportBase : ITransport
[LoggerMessage(Level = LogLevel.Error, Message = "{EndpointName} transport send failed for message ID '{MessageId}'.")]
private protected partial void LogTransportSendFailed(string endpointName, string messageId, Exception exception);
+ [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} transport sending message. Message: '{Message}'.")]
+ private protected partial void LogTransportSendingMessageSensitive(string endpointName, string message);
+
+ ///
+ /// Logs a sending message at Trace level if trace logging is enabled.
+ ///
+ /// The JSON-RPC message to log.
+ private protected void LogTransportSendingMessageSensitive(JsonRpcMessage message)
+ {
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ LogTransportSendingMessageSensitive(Name, JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage));
+ }
+ }
+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} transport reading messages.")]
private protected partial void LogTransportEnteringReadMessagesLoop(string endpointName);
diff --git a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
index 7747d7f..1ab106e 100644
--- a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
@@ -74,7 +74,9 @@ public class StreamServerTransport : TransportBase
try
{
- await JsonSerializer.SerializeAsync(_outputStream, message, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), cancellationToken).ConfigureAwait(false);
+ var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
+ LogTransportSendingMessageSensitive(Name, json);
+ await _outputStream.WriteAsync(Encoding.UTF8.GetBytes(json), cancellationToken).ConfigureAwait(false);
await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
await _outputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
index cbe44da..3f57566 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
@@ -1,4 +1,5 @@
-using ModelContextProtocol.Protocol;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
@@ -21,6 +22,14 @@ public class StdioServerTransportTests : LoggedTest
InitializationTimeout = TimeSpan.FromSeconds(10),
ServerInstructions = "Test Instructions"
};
+
+ // Override the LoggerFactory to use Trace level for testing Trace-level logging
+ LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
+ {
+ builder.AddProvider(XunitLoggerProvider);
+ builder.AddProvider(MockLoggerProvider);
+ builder.SetMinimumLevel(LogLevel.Trace);
+ });
}
[Fact(Skip="https://github.com/modelcontextprotocol/csharp-sdk/issues/143")]
@@ -193,4 +202,59 @@ public class StdioServerTransportTests : LoggedTest
Assert.True(magnifyingGlassFound, "Magnifying glass emoji not found in result");
Assert.True(rocketFound, "Rocket emoji not found in result");
}
+
+ [Fact]
+ public async Task SendMessageAsync_Should_Log_At_Trace_Level()
+ {
+ // Arrange
+ using var output = new MemoryStream();
+
+ await using var transport = new StreamServerTransport(
+ new Pipe().Reader.AsStream(),
+ output,
+ loggerFactory: LoggerFactory);
+
+ // Act
+ var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
+ await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
+
+ // Assert
+ var traceLogMessages = MockLoggerProvider.LogMessages
+ .Where(x => x.LogLevel == LogLevel.Trace && x.Message.Contains("transport sending message"))
+ .ToList();
+
+ Assert.NotEmpty(traceLogMessages);
+ Assert.Contains(traceLogMessages, x => x.Message.Contains("\"method\":\"test\"") && x.Message.Contains("\"id\":44"));
+ }
+
+ [Fact]
+ public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
+ {
+ // Arrange
+ var message = new JsonRpcRequest { Method = "test", Id = new RequestId(99) };
+ var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
+
+ Pipe pipe = new();
+ using var input = pipe.Reader.AsStream();
+
+ await using var transport = new StreamServerTransport(
+ input,
+ Stream.Null,
+ loggerFactory: LoggerFactory);
+
+ // Act
+ await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\n"), TestContext.Current.CancellationToken);
+
+ // Wait for the message to be processed
+ var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
+ Assert.True(canRead, "Nothing to read here from transport message reader");
+
+ // Assert
+ var traceLogMessages = MockLoggerProvider.LogMessages
+ .Where(x => x.LogLevel == LogLevel.Trace && x.Message.Contains("transport received message"))
+ .ToList();
+
+ Assert.NotEmpty(traceLogMessages);
+ Assert.Contains(traceLogMessages, x => x.Message.Contains("\"method\":\"test\"") && x.Message.Contains("\"id\":99"));
+ }
}
From aaf2246d532c0e1f9583313f83ddd89adb5d8f4a Mon Sep 17 00:00:00 2001
From: Stephen Toub
Date: Thu, 29 Jan 2026 14:29:08 -0500
Subject: [PATCH 009/320] Use Process.Kill(entireProcessTree: true) on .NET for
faster process termination (#1187)
---
.../ProcessHelper.cs | 83 +++++++++----------
1 file changed, 37 insertions(+), 46 deletions(-)
diff --git a/src/ModelContextProtocol.Core/ProcessHelper.cs b/src/ModelContextProtocol.Core/ProcessHelper.cs
index c8bae0c..a0e2498 100644
--- a/src/ModelContextProtocol.Core/ProcessHelper.cs
+++ b/src/ModelContextProtocol.Core/ProcessHelper.cs
@@ -8,36 +8,25 @@ namespace ModelContextProtocol;
///
internal static class ProcessHelper
{
- private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
- private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
-
- ///
- /// Kills a process and all of its child processes (entire process tree).
- ///
- /// The process to terminate along with its child processes.
- ///
- /// This method uses a default timeout of 30 seconds when waiting for processes to exit.
- /// On Windows, this uses the "taskkill" command with the /T flag.
- /// On non-Windows platforms, it recursively identifies and terminates child processes.
- ///
- public static void KillTree(this Process process) => process.KillTree(_defaultTimeout);
-
///
/// Kills a process and all of its child processes (entire process tree) with a specified timeout.
///
/// The process to terminate along with its child processes.
/// The maximum time to wait for the processes to exit.
///
- /// On Windows, this uses the "taskkill" command with the /T flag to terminate the process tree.
- /// On non-Windows platforms, it recursively identifies and terminates child processes.
+ /// On .NET Core 3.0+ this uses Process.Kill(entireProcessTree: true).
+ /// On .NET Standard 2.0, it uses platform-specific commands (taskkill on Windows, pgrep/kill on Unix).
/// The method waits for the specified timeout for processes to exit before continuing.
/// This is particularly useful for applications that spawn child processes (like Node.js)
/// that wouldn't be terminated automatically when the parent process exits.
///
public static void KillTree(this Process process, TimeSpan timeout)
{
+#if NETSTANDARD2_0
+ // Process.Kill(entireProcessTree) is not available on .NET Standard 2.0.
+ // Use platform-specific commands to kill the process tree.
var pid = process.Id;
- if (_isWindows)
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
RunProcessAndWaitForExit(
"taskkill",
@@ -53,8 +42,20 @@ internal static class ProcessHelper
{
KillProcessUnix(childId, timeout);
}
+
KillProcessUnix(pid, timeout);
}
+#else
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch
+ {
+ // Process has already exited
+ return;
+ }
+#endif
// wait until the process finishes exiting/getting killed.
// We don't want to wait forever here because the task is already supposed to be dieing, we just want to give it long enough
@@ -62,26 +63,20 @@ internal static class ProcessHelper
process.WaitForExit((int)timeout.TotalMilliseconds);
}
+#if NETSTANDARD2_0
private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout)
{
- var exitcode = RunProcessAndWaitForExit(
- "pgrep",
- $"-P {parentId}",
- timeout,
- out var stdout);
+ int exitcode = RunProcessAndWaitForExit("pgrep", $"-P {parentId}", timeout, out var stdout);
if (exitcode == 0 && !string.IsNullOrEmpty(stdout))
{
using var reader = new StringReader(stdout);
- while (true)
+ while (reader.ReadLine() is string text)
{
- var text = reader.ReadLine();
- if (text == null)
- return;
-
if (int.TryParse(text, out var id))
{
children.Add(id);
+
// Recursively get the children
GetAllChildIdsUnix(id, children, timeout);
}
@@ -89,14 +84,8 @@ internal static class ProcessHelper
}
}
- private static void KillProcessUnix(int processId, TimeSpan timeout)
- {
- RunProcessAndWaitForExit(
- "kill",
- $"-TERM {processId}",
- timeout,
- out var _);
- }
+ private static void KillProcessUnix(int processId, TimeSpan timeout) =>
+ RunProcessAndWaitForExit("kill", $"-TERM {processId}", timeout, out _);
private static int RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string? stdout)
{
@@ -112,19 +101,21 @@ internal static class ProcessHelper
stdout = null;
- var process = Process.Start(startInfo);
- if (process == null)
- return -1;
+ if (Process.Start(startInfo) is { } process)
+ {
+ if (process.WaitForExit((int)timeout.TotalMilliseconds))
+ {
+ stdout = process.StandardOutput.ReadToEnd();
+ }
+ else
+ {
+ process.Kill();
+ }
- if (process.WaitForExit((int)timeout.TotalMilliseconds))
- {
- stdout = process.StandardOutput.ReadToEnd();
- }
- else
- {
- process.Kill();
+ return process.ExitCode;
}
- return process.ExitCode;
+ return -1;
}
+#endif
}
From 482e311a70c54c92118e275bce82a7820a4f827a Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 17:20:52 -0500
Subject: [PATCH 010/320] Fix StdioServerTransportTests to use Debug logging
level instead of Trace (#1209)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Transport/StdioServerTransportTests.cs | 32 ++++++++++++-------
1 file changed, 20 insertions(+), 12 deletions(-)
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
index 3f57566..54a1a96 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
@@ -22,14 +22,6 @@ public class StdioServerTransportTests : LoggedTest
InitializationTimeout = TimeSpan.FromSeconds(10),
ServerInstructions = "Test Instructions"
};
-
- // Override the LoggerFactory to use Trace level for testing Trace-level logging
- LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
- {
- builder.AddProvider(XunitLoggerProvider);
- builder.AddProvider(MockLoggerProvider);
- builder.SetMinimumLevel(LogLevel.Trace);
- });
}
[Fact(Skip="https://github.com/modelcontextprotocol/csharp-sdk/issues/143")]
@@ -207,19 +199,27 @@ public class StdioServerTransportTests : LoggedTest
public async Task SendMessageAsync_Should_Log_At_Trace_Level()
{
// Arrange
+ var mockLoggerProvider = new MockLoggerProvider();
+ using var traceLoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
+ {
+ builder.AddProvider(XunitLoggerProvider);
+ builder.AddProvider(mockLoggerProvider);
+ builder.SetMinimumLevel(LogLevel.Trace);
+ });
+
using var output = new MemoryStream();
await using var transport = new StreamServerTransport(
new Pipe().Reader.AsStream(),
output,
- loggerFactory: LoggerFactory);
+ loggerFactory: traceLoggerFactory);
// Act
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
// Assert
- var traceLogMessages = MockLoggerProvider.LogMessages
+ var traceLogMessages = mockLoggerProvider.LogMessages
.Where(x => x.LogLevel == LogLevel.Trace && x.Message.Contains("transport sending message"))
.ToList();
@@ -231,6 +231,14 @@ public class StdioServerTransportTests : LoggedTest
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
{
// Arrange
+ var mockLoggerProvider = new MockLoggerProvider();
+ using var traceLoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
+ {
+ builder.AddProvider(XunitLoggerProvider);
+ builder.AddProvider(mockLoggerProvider);
+ builder.SetMinimumLevel(LogLevel.Trace);
+ });
+
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(99) };
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
@@ -240,7 +248,7 @@ public class StdioServerTransportTests : LoggedTest
await using var transport = new StreamServerTransport(
input,
Stream.Null,
- loggerFactory: LoggerFactory);
+ loggerFactory: traceLoggerFactory);
// Act
await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\n"), TestContext.Current.CancellationToken);
@@ -250,7 +258,7 @@ public class StdioServerTransportTests : LoggedTest
Assert.True(canRead, "Nothing to read here from transport message reader");
// Assert
- var traceLogMessages = MockLoggerProvider.LogMessages
+ var traceLogMessages = mockLoggerProvider.LogMessages
.Where(x => x.LogLevel == LogLevel.Trace && x.Message.Contains("transport received message"))
.ToList();
From 7c85ac6706d9c22ba9b2e2229c785da2b62b7b8d Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 17:23:21 -0500
Subject: [PATCH 011/320] Include response body in HttpRequestException for
transport client errors (#1193)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
src/Common/HttpResponseMessageExtensions.cs | 66 +++++++++++++++++
.../Authentication/ClientOAuthProvider.cs | 4 +-
.../Client/SseClientSessionTransport.cs | 9 ++-
.../StreamableHttpClientSessionTransport.cs | 2 +-
.../ModelContextProtocol.Core.csproj | 1 +
.../Transport/HttpClientTransportTests.cs | 73 +++++++++++++++++++
6 files changed, 149 insertions(+), 6 deletions(-)
create mode 100644 src/Common/HttpResponseMessageExtensions.cs
diff --git a/src/Common/HttpResponseMessageExtensions.cs b/src/Common/HttpResponseMessageExtensions.cs
new file mode 100644
index 0000000..05ef092
--- /dev/null
+++ b/src/Common/HttpResponseMessageExtensions.cs
@@ -0,0 +1,66 @@
+using System.Net;
+using System.Net.Http;
+
+namespace ModelContextProtocol;
+
+///
+/// Extension methods for .
+///
+internal static class HttpResponseMessageExtensions
+{
+ private const int MaxResponseBodyLength = 1024;
+
+ ///
+ /// Throws an if the property is .
+ /// Unlike , this method includes the response body in the exception message
+ /// to help diagnose issues when the server returns error details in the response body.
+ ///
+ /// The HTTP response message to check.
+ /// The token to monitor for cancellation requests.
+ /// A task that represents the asynchronous operation.
+ /// The response status code does not indicate success.
+ public static async Task EnsureSuccessStatusCodeWithResponseBodyAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default)
+ {
+ if (!response.IsSuccessStatusCode)
+ {
+ string? responseBody = null;
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+ responseBody = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
+
+ if (responseBody.Length > MaxResponseBodyLength)
+ {
+ responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "...";
+ }
+ }
+ catch
+ {
+ // Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it.
+ }
+
+ throw CreateHttpRequestException(response, responseBody);
+ }
+ }
+
+ ///
+ /// Creates an for a non-success response, including the response body in the message.
+ ///
+ /// The HTTP response message.
+ /// The response body content, if available.
+ /// An with the response details.
+ public static HttpRequestException CreateHttpRequestException(HttpResponseMessage response, string? responseBody)
+ {
+ int statusCodeInt = (int)response.StatusCode;
+ string message = string.IsNullOrEmpty(responseBody)
+ ? $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase})."
+ : $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase}). Response body: {responseBody}";
+
+#if NET
+ return new HttpRequestException(message, inner: null, response.StatusCode);
+#else
+ return new HttpRequestException(message);
+#endif
+ }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index 7512655..6b45e44 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -499,7 +499,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
};
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
- httpResponse.EnsureSuccessStatusCode();
+ await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
var tokens = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
LogOAuthAuthorizationCompleted();
@@ -544,7 +544,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
using var httpResponse = await _httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
if (requireSuccess)
{
- httpResponse.EnsureSuccessStatusCode();
+ await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
}
else if (!httpResponse.IsSuccessStatusCode)
{
diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
index fa9ee97..085f8cc 100644
--- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
@@ -88,16 +88,19 @@ internal sealed partial class SseClientSessionTransport : TransportBase
if (!response.IsSuccessStatusCode)
{
+ // Read the response body once to include in both logging and exception
+ string responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
if (_logger.IsEnabled(LogLevel.Trace))
{
- LogRejectedPostSensitive(Name, messageId, await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
+ LogRejectedPostSensitive(Name, messageId, responseBody);
}
else
{
LogRejectedPost(Name, messageId);
}
- response.EnsureSuccessStatusCode();
+ throw HttpResponseMessageExtensions.CreateHttpRequestException(response, responseBody);
}
}
@@ -148,7 +151,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
+ await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
index c33d353..c80b6a4 100644
--- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
@@ -61,7 +61,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
{
// Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it.
using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
+ await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
}
// This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception.
diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
index a1ac1a6..63b7bbd 100644
--- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
+++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
@@ -25,6 +25,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs
index fc1ac2d..d1f03b7 100644
--- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs
@@ -82,6 +82,32 @@ public class HttpClientTransportTests : LoggedTest
Assert.Equal(1, retries);
}
+ [Fact]
+ public async Task ConnectAsync_Throws_HttpRequestException_With_ResponseBody_On_ErrorStatusCode()
+ {
+ using var mockHttpHandler = new MockHttpHandler();
+ using var httpClient = new HttpClient(mockHttpHandler);
+ await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
+
+ const string errorDetails = "Bad request: Invalid MCP protocol version";
+ mockHttpHandler.RequestHandler = (request) =>
+ {
+ return Task.FromResult(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.BadRequest,
+ ReasonPhrase = "Bad Request",
+ Content = new StringContent(errorDetails)
+ });
+ };
+
+ var httpException = await Assert.ThrowsAsync(() => transport.ConnectAsync(TestContext.Current.CancellationToken));
+ Assert.Contains(errorDetails, httpException.Message);
+ Assert.Contains("400", httpException.Message);
+#if NET
+ Assert.Equal(HttpStatusCode.BadRequest, httpException.StatusCode);
+#endif
+ }
+
[Fact]
public async Task SendMessageAsync_Handles_Accepted_Response()
{
@@ -120,6 +146,53 @@ public class HttpClientTransportTests : LoggedTest
Assert.True(true);
}
+ [Fact]
+ public async Task SendMessageAsync_Throws_HttpRequestException_With_ResponseBody_On_ErrorStatusCode()
+ {
+ using var mockHttpHandler = new MockHttpHandler();
+ using var httpClient = new HttpClient(mockHttpHandler);
+ await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
+
+ var firstCall = true;
+ const string errorDetails = "Invalid JSON-RPC message format: missing 'id' field";
+
+ mockHttpHandler.RequestHandler = (request) =>
+ {
+ if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint")
+ {
+ return Task.FromResult(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.BadRequest,
+ ReasonPhrase = "Bad Request",
+ Content = new StringContent(errorDetails)
+ });
+ }
+ else
+ {
+ if (!firstCall)
+ throw new IOException("Abort");
+ else
+ firstCall = false;
+
+ return Task.FromResult(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n")
+ });
+ }
+ };
+
+ await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
+ var httpException = await Assert.ThrowsAsync(() =>
+ session.SendMessageAsync(new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(44) }, CancellationToken.None));
+
+ Assert.Contains(errorDetails, httpException.Message);
+ Assert.Contains("400", httpException.Message);
+#if NET
+ Assert.Equal(HttpStatusCode.BadRequest, httpException.StatusCode);
+#endif
+ }
+
[Fact]
public async Task ReceiveMessagesAsync_Handles_Messages()
{
From 8e57ac6b0e89ee3f4414ff6b17c34bb0164d705d Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 29 Jan 2026 22:06:17 -0500
Subject: [PATCH 012/320] Centralize test timeout constants to fix sporadic CI
failures (#1210)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericstj <8918108+ericstj@users.noreply.github.com>
Co-authored-by: Eric StJohn
---
tests/Common/Utils/TestConstants.cs | 13 +++++++++++++
.../MapMcpStreamableHttpTests.cs | 3 ++-
.../ResumabilityIntegrationTests.cs | 3 ++-
.../ServerConformanceTests.cs | 2 +-
.../SseIntegrationTests.cs | 3 ++-
.../StreamableHttpClientConformanceTests.cs | 3 ++-
.../StreamableHttpServerConformanceTests.cs | 2 +-
.../Client/McpClientResourceSubscriptionTests.cs | 13 +++++++------
.../McpClientTaskSamplingElicitationTests.cs | 15 ++++++++-------
.../Protocol/UrlElicitationTests.cs | 3 ++-
.../Server/AutomaticInputRequiredStatusTests.cs | 8 ++++----
.../Server/McpServerTaskMethodsTests.cs | 2 +-
.../Server/McpServerTests.cs | 8 ++++----
.../Server/TaskCancellationIntegrationTests.cs | 13 +++++++------
14 files changed, 56 insertions(+), 35 deletions(-)
create mode 100644 tests/Common/Utils/TestConstants.cs
diff --git a/tests/Common/Utils/TestConstants.cs b/tests/Common/Utils/TestConstants.cs
new file mode 100644
index 0000000..eae9a54
--- /dev/null
+++ b/tests/Common/Utils/TestConstants.cs
@@ -0,0 +1,13 @@
+namespace ModelContextProtocol.Tests.Utils;
+
+///
+/// Provides centralized constants for tests
+///
+public static class TestConstants
+{
+ ///
+ /// Default timeout for test operations that may be affected by CI machine load.
+ /// Set to 60 seconds to provide sufficient buffer for slow CI environments.
+ ///
+ public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);
+}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
index 0629ae3..8f04cbe 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Primitives;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
@@ -251,7 +252,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Assert.NotNull(serverInfo);
Assert.False(string.IsNullOrEmpty(resumedSessionId));
- await serverTcs.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await serverTcs.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
await using var resumeTransport = new HttpClientTransport(new()
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs
index f6c8f3a..5f83e05 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs
@@ -10,6 +10,7 @@ using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.AspNetCore.Tests;
@@ -279,7 +280,7 @@ public class ResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) :
[Fact]
public async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconnection()
{
- var timeout = TimeSpan.FromSeconds(10);
+ var timeout = TestConstants.DefaultTimeout;
using var faultingStreamHandler = new FaultingStreamHandler()
{
InnerHandler = SocketsHttpHandler,
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index 3948ad1..031a67a 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -83,7 +83,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
try
{
- await _serverTask.WaitAsync(TimeSpan.FromSeconds(5));
+ await _serverTask.WaitAsync(TestConstants.DefaultTimeout);
}
catch
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs
index 1a48cc8..11df047 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs
@@ -8,6 +8,7 @@ using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
using System.Text.Json.Serialization;
using TestServerWithHosting.Tools;
@@ -110,7 +111,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
// Send a test message through POST endpoint
await mcpClient.SendNotificationAsync("test/notification", new Envelope { Message = "Hello from client!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: TestContext.Current.CancellationToken);
- var message = await receivedNotification.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ var message = await receivedNotification.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.Equal("Hello from server!", message);
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs
index e4ee2a1..4cf7b71 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs
@@ -6,6 +6,7 @@ using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
@@ -245,7 +246,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken))
{
- var observedSessionId = await resumeServer.GetStarted.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ var observedSessionId = await resumeServer.GetStarted.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.Equal(sessionId, observedSessionId);
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
index a6b0fc7..c6d5766 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
@@ -249,7 +249,7 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
await Task.WhenAll(echoTasks);
}
- [Fact]
+ [Fact(Skip = "https://github.com/modelcontextprotocol/csharp-sdk/issues/1211")]
public async Task GetRequest_Receives_UnsolicitedNotifications()
{
McpServer? server = null;
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
index 84ee33f..2ee3cec 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
using System.ComponentModel;
namespace ModelContextProtocol.Tests.Client;
@@ -50,7 +51,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase
cancellationToken: TestContext.Current.CancellationToken);
// Assert
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout);
var receivedNotification = await notificationReceived.Task.WaitAsync(cts.Token);
Assert.NotNull(receivedNotification);
Assert.Equal(resourceUri, receivedNotification.Uri);
@@ -92,7 +93,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase
cancellationToken: TestContext.Current.CancellationToken);
// Assert
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout);
await correctNotificationReceived.Task.WaitAsync(cts.Token);
// Give a small delay to ensure no other notifications are processed
@@ -168,7 +169,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase
cancellationToken: TestContext.Current.CancellationToken);
// Assert
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout);
var receivedNotification = await notificationReceived.Task.WaitAsync(cts.Token);
Assert.NotNull(receivedNotification);
Assert.Equal(resourceUri.AbsoluteUri, receivedNotification.Uri);
@@ -263,7 +264,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase
cancellationToken: TestContext.Current.CancellationToken);
// Assert
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout);
var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, TestContext.Current.CancellationToken);
await Task.WhenAll(
notification1Received.Task.WaitAsync(combined.Token),
@@ -341,7 +342,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase
cancellationToken: TestContext.Current.CancellationToken);
// Assert - Both handlers should be invoked
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout);
var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, TestContext.Current.CancellationToken);
await Task.WhenAll(
handler1Called.Task.WaitAsync(combined.Token),
@@ -360,7 +361,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase
cancellationToken: TestContext.Current.CancellationToken);
// Wait for handler2 to be called again
- using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var cts2 = new CancellationTokenSource(TestConstants.DefaultTimeout);
var combined2 = CancellationTokenSource.CreateLinkedTokenSource(cts2.Token, TestContext.Current.CancellationToken);
await handler2CalledAgain.Task.WaitAsync(combined2.Token);
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs
index f000fc0..906b4f4 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Client;
@@ -219,7 +220,7 @@ public class McpClientTaskSamplingElicitationTests : ClientServerTestBase
Assert.Equal(McpTaskStatus.Working, mcpTask.Status);
// Wait for sampling to complete
- await samplingCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Poll until task is complete
McpTask taskStatus;
@@ -375,7 +376,7 @@ public class McpClientTaskSamplingElicitationTests : ClientServerTestBase
Assert.Equal(McpTaskStatus.Working, mcpTask.Status);
// Wait for elicitation to complete
- await elicitationCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await elicitationCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Poll until task is complete
McpTask taskStatus;
@@ -488,7 +489,7 @@ public class McpClientTaskSamplingElicitationTests : ClientServerTestBase
TestContext.Current.CancellationToken);
// Wait for sampling to start
- await samplingStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await samplingStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Act - Cancel the task
var cancelledTask = await Server.CancelTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken);
@@ -600,8 +601,8 @@ public class McpClientTaskSamplingElicitationTests : ClientServerTestBase
// Wait for both Working and Completed notifications to arrive
// The notifications are sent asynchronously so we need to wait for both
await Task.WhenAll(
- workingNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken),
- completedNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
+ workingNotificationReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken),
+ completedNotificationReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
// Assert - Should have received notifications for status transitions
await notificationHandler.DisposeAsync();
@@ -653,7 +654,7 @@ public class McpClientTaskSamplingElicitationTests : ClientServerTestBase
TestContext.Current.CancellationToken);
// Wait for sampling attempt
- await samplingAttempted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await samplingAttempted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Poll until task status changes
McpTask taskStatus;
@@ -703,7 +704,7 @@ public class McpClientTaskSamplingElicitationTests : ClientServerTestBase
TestContext.Current.CancellationToken);
// Wait for elicitation attempt
- await elicitationAttempted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await elicitationAttempted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Poll until task status changes
McpTask taskStatus;
diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs
index 4291383..bf4c67d 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs
@@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.Tests.Configuration;
@@ -275,7 +276,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
Assert.NotNull(capturedMessage);
Assert.Contains(capturedElicitationId, capturedUrl);
- var notifiedElicitationId = await completionNotification.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ var notifiedElicitationId = await completionNotification.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.Equal(capturedElicitationId, notifiedElicitationId);
}
diff --git a/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs b/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs
index 2cee920..788e56c 100644
--- a/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs
@@ -95,7 +95,7 @@ public class AutomaticInputRequiredStatusTests : LoggedTest
cancellationToken: TestContext.Current.CancellationToken);
// Wait for the sampling request to be received by the client
- await samplingRequestReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await samplingRequestReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Check the task status while sampling is in progress
var statusDuringSampling = await taskStore.GetTaskAsync(
@@ -194,7 +194,7 @@ public class AutomaticInputRequiredStatusTests : LoggedTest
cancellationToken: TestContext.Current.CancellationToken);
// Wait for the elicitation request to be received
- await elicitationRequestReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await elicitationRequestReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Check the task status while elicitation is in progress
var statusDuringElicitation = await taskStore.GetTaskAsync(
@@ -301,7 +301,7 @@ public class AutomaticInputRequiredStatusTests : LoggedTest
cancellationToken: TestContext.Current.CancellationToken);
// Wait for sampling to complete inside the tool
- await samplingCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Small delay to ensure status update is processed
await Task.Delay(50, TestContext.Current.CancellationToken);
@@ -380,7 +380,7 @@ public class AutomaticInputRequiredStatusTests : LoggedTest
arguments: new Dictionary { ["prompt"] = "Hello" },
cancellationToken: TestContext.Current.CancellationToken);
- await samplingCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Assert - No task should exist (tool was not called as a task)
var tasks = await taskStore.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs
index 78a149c..d908bbb 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs
@@ -755,7 +755,7 @@ public class McpServerTaskMethodsTests : LoggedTest
await transport.SendClientMessageAsync(initializeRequest, cancellationToken);
// Wait for the initialize response to be sent
- await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
+ await tcs.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
}
#endregion
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
index 4a52d64..6371629 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
@@ -692,7 +692,7 @@ public class McpServerTests : LoggedTest
TestContext.Current.CancellationToken
);
- var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ var error = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.NotNull(error.Error);
Assert.Equal((int)errorCode, error.Error.Code);
@@ -744,7 +744,7 @@ public class McpServerTests : LoggedTest
TestContext.Current.CancellationToken
);
- var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ var error = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.NotNull(error.Error);
Assert.Equal((int)ErrorCode, error.Error.Code);
@@ -786,7 +786,7 @@ public class McpServerTests : LoggedTest
}
);
- var response = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10));
+ var response = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout);
Assert.NotNull(response);
assertResult(server, response.Result);
@@ -887,7 +887,7 @@ public class McpServerTests : LoggedTest
await transport.SendClientMessageAsync(initializeRequest, cancellationToken);
// Wait for the initialize response to be sent
- await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
+ await tcs.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
}
private sealed class TestServerForIChatClient(bool supportsSampling) : McpServer
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
index ed8c5ac..ed3bf82 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Server;
@@ -77,10 +78,10 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
Assert.NotNull(callResult.Task);
// Wait for the tool to start executing
- await _toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Assert - Wait for the cancellation to fire (should happen when TTL expires)
- var cancelled = await _toolCancellationFired.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.True(cancelled, "Tool's CancellationToken should have been triggered when TTL expired");
// Note: TTL-based expiration does not explicitly set task status to Cancelled.
@@ -108,13 +109,13 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
string taskId = callResult.Task.TaskId;
// Wait for the tool to start executing
- await _toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Act - Explicitly cancel the task
var cancelledTask = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
// Assert - Wait for the cancellation to propagate to the tool
- var cancelled = await _toolCancellationFired.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.True(cancelled, "Tool's CancellationToken should have been triggered by explicit cancellation");
// Verify task status
@@ -255,7 +256,7 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
{
lock (_lock)
{
- return _toolStarts[marker].Task.WaitAsync(TimeSpan.FromSeconds(5), ct);
+ return _toolStarts[marker].Task.WaitAsync(TestConstants.DefaultTimeout, ct);
}
}
@@ -263,7 +264,7 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
{
lock (_lock)
{
- return _toolCancellations[marker].Task.WaitAsync(TimeSpan.FromSeconds(5), ct);
+ return _toolCancellations[marker].Task.WaitAsync(TestConstants.DefaultTimeout, ct);
}
}
From f9dec13660fb7052cf65bff4e72d85945d38615c Mon Sep 17 00:00:00 2001
From: Stephen Toub
Date: Thu, 29 Jan 2026 23:04:51 -0500
Subject: [PATCH 013/320] Fix race condition in SSE GET request initialization
(#1212)
---
.../StreamableHttpHandler.cs | 5 -----
.../Server/StreamableHttpServerTransport.cs | 6 ++++++
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index 6e11b9b..c50e513 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -154,11 +154,6 @@ internal sealed class StreamableHttpHandler(
{
await using var _ = await session.AcquireReferenceAsync(cancellationToken);
InitializeSseResponse(context);
-
- // We should flush headers to indicate a 200 success quickly, because the initialization response
- // will be sent in response to a different POST request. It might be a while before we send a message
- // over this response body.
- await context.Response.Body.FlushAsync(cancellationToken);
await session.Transport.HandleGetRequestAsync(context.Response.Body, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
index 6307726..55b6650 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
@@ -125,6 +125,12 @@ public sealed class StreamableHttpServerTransport : ITransport
var primingItem = await _storeSseWriter.WriteEventAsync(SseItem.Prime(), cancellationToken).ConfigureAwait(false);
await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
}
+ else
+ {
+ // If there's no priming write, flush the stream to ensure HTTP response headers are
+ // sent to the client now that the transport is ready to accept messages via SendMessageAsync.
+ await sseResponseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
}
// Wait for the response to be written before returning from the handler.
From 283caf9810c42522daf5f2ad5053653ecba5d24b Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 15:21:28 -0500
Subject: [PATCH 014/320] Fix race condition in
AutomaticInputRequiredStatusTests polling logic (#1223)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericstj <8918108+ericstj@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Server/AutomaticInputRequiredStatusTests.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs b/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs
index 788e56c..1f5c51c 100644
--- a/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs
@@ -119,7 +119,7 @@ public class AutomaticInputRequiredStatusTests : LoggedTest
finalStatus = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
maxAttempts--;
}
- while (finalStatus?.Status == McpTaskStatus.Working && maxAttempts > 0);
+ while (finalStatus?.Status is not McpTaskStatus.Completed && maxAttempts > 0);
// Assert - Status should have been InputRequired during sampling
Assert.Contains(McpTaskStatus.InputRequired, statusesDuringSampling);
@@ -218,7 +218,7 @@ public class AutomaticInputRequiredStatusTests : LoggedTest
finalStatus = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
maxAttempts--;
}
- while (finalStatus?.Status == McpTaskStatus.Working && maxAttempts > 0);
+ while (finalStatus?.Status is not McpTaskStatus.Completed && maxAttempts > 0);
// Assert - Status should have been InputRequired during elicitation
Assert.Contains(McpTaskStatus.InputRequired, statusesDuringElicitation);
From a83d6198a53969ef262bd0d04e08872850418b61 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 13:47:11 -0800
Subject: [PATCH 015/320] Centralize test timeout constants to fix CI flakiness
(#1224)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericstj <8918108+ericstj@users.noreply.github.com>
Co-authored-by: Eric StJohn
---
tests/Common/Utils/TestConstants.cs | 12 +++++++++++
.../OAuth/OAuthTestBase.cs | 5 +++++
.../ServerConformanceTests.cs | 2 +-
.../Utils/KestrelInMemoryTest.cs | 2 +-
.../Program.cs | 21 ++++++++++++++++++-
.../EverythingSseServerFixture.cs | 5 +++--
.../StdioServerIntegrationTests.cs | 2 +-
7 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/tests/Common/Utils/TestConstants.cs b/tests/Common/Utils/TestConstants.cs
index eae9a54..4a337df 100644
--- a/tests/Common/Utils/TestConstants.cs
+++ b/tests/Common/Utils/TestConstants.cs
@@ -10,4 +10,16 @@ public static class TestConstants
/// Set to 60 seconds to provide sufficient buffer for slow CI environments.
///
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);
+
+ ///
+ /// Timeout for HttpClient operations in tests.
+ /// Set to 60 seconds to provide sufficient buffer for slow CI environments.
+ ///
+ public static readonly TimeSpan HttpClientTimeout = TimeSpan.FromSeconds(60);
+
+ ///
+ /// Timeout for short-lived HTTP requests during polling operations.
+ /// Set to 2 seconds for quick failure detection while polling.
+ ///
+ public static readonly TimeSpan HttpClientPollingTimeout = TimeSpan.FromSeconds(2);
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
index 52b5616..5d66ee8 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
@@ -83,6 +83,11 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
protected async Task StartMcpServerAsync(string path = "", string? authScheme = null)
{
+ // Wait for the OAuth server to be ready before starting the MCP server.
+ // This prevents race conditions in CI where the OAuth server may not be
+ // fully initialized when the first test request is made.
+ await TestOAuthServer.ServerStarted.WaitAsync(TestContext.Current.CancellationToken);
+
Builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}";
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index 031a67a..02a98b8 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -47,7 +47,7 @@ public class ServerConformanceTests : IAsyncLifetime
// Wait for server to be ready (retry for up to 30 seconds)
var timeout = TimeSpan.FromSeconds(30);
var stopwatch = Stopwatch.StartNew();
- using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+ using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout };
while (stopwatch.Elapsed < timeout)
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTest.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTest.cs
index c93a276..0bbff49 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTest.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTest.cs
@@ -42,7 +42,7 @@ public class KestrelInMemoryTest : LoggedTest
protected static void ConfigureHttpClient(HttpClient httpClient)
{
httpClient.BaseAddress = new Uri("http://localhost:5000/");
- httpClient.Timeout = TimeSpan.FromSeconds(10);
+ httpClient.Timeout = TestConstants.HttpClientTimeout;
}
public override void Dispose()
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index e13c731..3648363 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -33,6 +33,7 @@ public sealed class Program
private readonly ILoggerProvider? _loggerProvider;
private readonly IConnectionListenerFactory? _kestrelTransport;
+ private readonly TaskCompletionSource _serverStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
///
/// Initializes a new instance of the class with logging and transport parameters.
@@ -47,6 +48,11 @@ public sealed class Program
_kestrelTransport = kestrelTransport;
}
+ ///
+ /// Gets a task that completes when the server has started and is ready to accept connections.
+ ///
+ public Task ServerStarted => _serverStarted.Task;
+
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
public bool HasRefreshedToken { get; set; }
@@ -541,7 +547,20 @@ public sealed class Program
Console.WriteLine($"Demo Client ID: {clientId}");
Console.WriteLine($"Demo Client Secret: {clientSecret}");
- await app.RunAsync(cancellationToken);
+ await app.StartAsync(cancellationToken);
+ _serverStarted.TrySetResult();
+
+ // Wait until cancellation is requested
+ try
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when cancellation is requested
+ }
+
+ await app.StopAsync();
}
///
diff --git a/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs b/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs
index f12aff5..e579ff9 100644
--- a/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs
+++ b/tests/ModelContextProtocol.Tests/EverythingSseServerFixture.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Net;
+using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.Tests;
@@ -33,7 +34,7 @@ public class EverythingSseServerFixture : IAsyncDisposable
?? throw new InvalidOperationException($"Could not start process for {processStartInfo.FileName} with '{processStartInfo.Arguments}'.");
// Poll until the server is ready (up to 30 seconds)
- using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+ using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout };
var endpoint = $"http://localhost:{_port}/sse";
var deadline = DateTime.UtcNow.AddSeconds(30);
@@ -72,7 +73,7 @@ public class EverythingSseServerFixture : IAsyncDisposable
using var stopProcess = Process.Start(stopInfo)
?? throw new InvalidOperationException($"Could not stop process for {stopInfo.FileName} with '{stopInfo.Arguments}'.");
- await stopProcess.WaitForExitAsync(TimeSpan.FromSeconds(10));
+ await stopProcess.WaitForExitAsync(TestConstants.DefaultTimeout);
}
catch (Exception ex)
{
diff --git a/tests/ModelContextProtocol.Tests/StdioServerIntegrationTests.cs b/tests/ModelContextProtocol.Tests/StdioServerIntegrationTests.cs
index d14c376..88604f5 100644
--- a/tests/ModelContextProtocol.Tests/StdioServerIntegrationTests.cs
+++ b/tests/ModelContextProtocol.Tests/StdioServerIntegrationTests.cs
@@ -46,7 +46,7 @@ public class StdioServerIntegrationTests(ITestOutputHelper testOutputHelper) : L
// https://github.com/dotnet/runtime/issues/109432, https://github.com/dotnet/runtime/issues/44944
Assert.Equal(0, kill(process.Id, SIGINT));
- await process.WaitForExitAsync(TimeSpan.FromSeconds(10));
+ await process.WaitForExitAsync(TestConstants.DefaultTimeout);
Assert.True(process.HasExited);
Assert.Equal(0, process.ExitCode);
From 82bd98081dce43db53af3225d210ffd2030e24bb Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 14:07:49 -0800
Subject: [PATCH 016/320] Add test coverage for AdditionalHeaders in Streamable
HTTP transport (#1218)
---
.../MapMcpStreamableHttpTests.cs | 57 +++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
index 8f04cbe..2098e79 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using ModelContextProtocol.Client;
@@ -355,4 +356,60 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Assert.NotNull(capturedException);
Assert.Contains("event stream store", capturedException.Message, StringComparison.OrdinalIgnoreCase);
}
+
+ [Fact]
+ public async Task AdditionalHeaders_AreSent_InPostAndDeleteRequests()
+ {
+ Assert.SkipWhen(Stateless, "DELETE requests are not sent in stateless mode due to lack of session ID.");
+
+ bool wasPostRequest = false;
+ bool wasDeleteRequest = false;
+
+ Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools();
+
+ await using var app = Builder.Build();
+
+ app.Use(next =>
+ {
+ return async context =>
+ {
+ Assert.Equal("Bearer testToken", context.Request.Headers["Authorize"]);
+ if (context.Request.Method == HttpMethods.Post)
+ {
+ wasPostRequest = true;
+ }
+ else if (context.Request.Method == HttpMethods.Delete)
+ {
+ wasDeleteRequest = true;
+ }
+ await next(context);
+ };
+ });
+
+ app.MapMcp();
+
+ await app.StartAsync(TestContext.Current.CancellationToken);
+
+ var transportOptions = new HttpClientTransportOptions
+ {
+ Endpoint = new("http://localhost:5000/"),
+ Name = "In-memory Streamable HTTP Client",
+ TransportMode = HttpTransportMode.StreamableHttp,
+ AdditionalHeaders = new Dictionary
+ {
+ ["Authorize"] = "Bearer testToken"
+ },
+ };
+
+ await using var mcpClient = await ConnectAsync(transportOptions: transportOptions);
+
+ // Do a tool call to ensure there's more than just the initialize request
+ await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // Dispose the client to trigger the DELETE request
+ await mcpClient.DisposeAsync();
+
+ Assert.True(wasPostRequest, "POST request was not made");
+ Assert.True(wasDeleteRequest, "DELETE request was not made");
+ }
}
From e47f88419c8b8447d5756a5c2378e3adff44d3fa Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 17:09:24 -0500
Subject: [PATCH 017/320] Fix keyset pagination with monotonic UUIDv7-like task
IDs (#1215)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub
---
src/Common/Polyfills/System/IdHelpers.cs | 40 +++++
.../ModelContextProtocol.Core.csproj | 2 +-
.../Server/InMemoryMcpTaskStore.cs | 79 +++++++---
.../ModelContextProtocol.Tests.csproj | 12 ++
.../Server/InMemoryMcpTaskStoreTests.cs | 139 ++++++++++++++++++
5 files changed, 247 insertions(+), 25 deletions(-)
create mode 100644 src/Common/Polyfills/System/IdHelpers.cs
diff --git a/src/Common/Polyfills/System/IdHelpers.cs b/src/Common/Polyfills/System/IdHelpers.cs
new file mode 100644
index 0000000..a698cca
--- /dev/null
+++ b/src/Common/Polyfills/System/IdHelpers.cs
@@ -0,0 +1,40 @@
+using System.Threading;
+
+namespace System;
+
+///
+/// Provides helper methods for monotonic ID generation.
+///
+internal static class IdHelpers
+{
+ private static long s_counter;
+
+ ///
+ /// Creates a strictly monotonically increasing identifier string using 64-bit timestamp ticks
+ /// and a 64-bit counter, formatted as a 32-character hexadecimal string (GUID-like).
+ ///
+ /// The timestamp to embed in the identifier.
+ /// A new strictly monotonically increasing identifier string.
+ ///
+ ///
+ /// This method creates a 128-bit identifier composed of two 64-bit values:
+ /// - High 64 bits: from the timestamp
+ /// - Low 64 bits: A globally monotonically increasing counter
+ ///
+ ///
+ /// The resulting string is strictly monotonically increasing when compared lexicographically,
+ /// which is required for keyset pagination to work correctly. Unlike Guid.CreateVersion7,
+ /// which uses random bits for intra-millisecond uniqueness, this implementation guarantees
+ /// strict ordering for all identifiers regardless of when they were created.
+ ///
+ ///
+ public static string CreateMonotonicId(DateTimeOffset timestamp)
+ {
+ long ticks = timestamp.UtcTicks;
+ long counter = Interlocked.Increment(ref s_counter);
+
+ // Format as 32-character hex string (16 bytes = 128 bits)
+ // High 64 bits: timestamp ticks, Low 64 bits: counter
+ return $"{ticks:x16}{counter:x16}";
+ }
+}
diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
index 63b7bbd..bc4ae4c 100644
--- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
+++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
@@ -7,7 +7,6 @@
ModelContextProtocol.Core
Core .NET SDK for the Model Context Protocol (MCP)
README.md
- True
$(NoWarn);MCPEXP001
@@ -19,6 +18,7 @@
$(NoWarn);CS0436
+ true
diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
index c24b789..27156e9 100644
--- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
+++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
@@ -3,7 +3,11 @@ using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
+#if MCP_TEST_TIME_PROVIDER
+namespace ModelContextProtocol.Tests.Internal;
+#else
namespace ModelContextProtocol;
+#endif
///
/// Provides an in-memory implementation of for development and testing.
@@ -35,6 +39,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
private readonly int _pageSize;
private readonly int? _maxTasks;
private readonly int? _maxTasksPerSession;
+#if MCP_TEST_TIME_PROVIDER
+ private readonly TimeProvider _timeProvider;
+#endif
///
/// Initializes a new instance of the class.
@@ -120,6 +127,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
_pageSize = pageSize;
_maxTasks = maxTasks;
_maxTasksPerSession = maxTasksPerSession;
+#if MCP_TEST_TIME_PROVIDER
+ _timeProvider = TimeProvider.System;
+#endif
cleanupInterval ??= TimeSpan.FromMinutes(1);
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
@@ -128,6 +138,26 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
}
}
+#if MCP_TEST_TIME_PROVIDER
+ ///
+ /// Initializes a new instance of the class with a custom time provider.
+ /// This constructor is only available for testing purposes.
+ ///
+ internal InMemoryMcpTaskStore(
+ TimeSpan? defaultTtl,
+ TimeSpan? maxTtl,
+ TimeSpan? pollInterval,
+ TimeSpan? cleanupInterval,
+ int pageSize,
+ int? maxTasks,
+ int? maxTasksPerSession,
+ TimeProvider timeProvider)
+ : this(defaultTtl, maxTtl, pollInterval, cleanupInterval, pageSize, maxTasks, maxTasksPerSession)
+ {
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+#endif
+
///
public Task CreateTaskAsync(
McpTaskMetadata taskParams,
@@ -155,7 +185,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
}
var taskId = GenerateTaskId();
- var now = DateTimeOffset.UtcNow;
+ var now = GetUtcNow();
// Determine TTL: use requested, fall back to default, respect max limit
var ttl = taskParams.TimeToLive ?? _defaultTtl;
@@ -242,7 +272,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
var updatedEntry = new TaskEntry(entry)
{
Status = status,
- LastUpdatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = GetUtcNow(),
StoredResult = result
};
@@ -303,7 +333,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
{
Status = status,
StatusMessage = statusMessage,
- LastUpdatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = GetUtcNow(),
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -321,32 +351,22 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
string? sessionId = null,
CancellationToken cancellationToken = default)
{
- // Parse cursor: format is "CreatedAt|TaskId" for keyset pagination
- (DateTimeOffset, string)? parsedCursor = null;
- if (cursor != null)
- {
- var parts = cursor.Split('|');
- if (parts.Length == 2 &&
- DateTimeOffset.TryParse(parts[0], out var parsedDate))
- {
- parsedCursor = (parsedDate, parts[1]);
- }
- }
-
// Stream enumeration - filter by session, exclude expired, apply keyset pagination
var query = _tasks.Values
.Where(e => sessionId == null || e.SessionId == sessionId)
.Where(e => !IsExpired(e));
- // Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor
- if (parsedCursor is { } parsedCursorValue)
+ // Apply keyset filter if cursor provided: TaskId > cursor
+ // UUID v7 task IDs are monotonically increasing and inherently time-ordered
+ if (cursor != null)
{
- query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0);
+ query = query.Where(e => string.CompareOrdinal(e.TaskId, cursor) > 0);
}
- // Order by (CreatedAt, TaskId) for stable, deterministic pagination
+ // Order by TaskId for stable, deterministic pagination
+ // UUID v7 task IDs sort chronologically due to embedded timestamp
var page = query
- .OrderBy(e => (e.CreatedAt, e.TaskId))
+ .OrderBy(e => e.TaskId, StringComparer.Ordinal)
.Take(_pageSize + 1) // Take one extra to check if there's a next page
.Select(e => e.ToMcpTask())
.ToList();
@@ -356,7 +376,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
if (page.Count > _pageSize)
{
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
- nextCursor = $"{lastItemInPage.CreatedAt:O}|{lastItemInPage.TaskId}";
+ nextCursor = lastItemInPage.TaskId;
page.RemoveAt(_pageSize); // Remove the extra item
}
else
@@ -397,7 +417,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
var updatedEntry = new TaskEntry(entry)
{
Status = McpTaskStatus.Cancelled,
- LastUpdatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = GetUtcNow(),
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -417,12 +437,23 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
_cleanupTimer?.Dispose();
}
- private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
+ private string GenerateTaskId() =>
+ IdHelpers.CreateMonotonicId(GetUtcNow());
private static bool IsTerminalStatus(McpTaskStatus status) =>
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
+#if MCP_TEST_TIME_PROVIDER
+ private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow();
+#else
+ private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
+#endif
+
+#if MCP_TEST_TIME_PROVIDER
+ private bool IsExpired(TaskEntry entry)
+#else
private static bool IsExpired(TaskEntry entry)
+#endif
{
if (entry.TimeToLive == null)
{
@@ -430,7 +461,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
}
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
- return DateTimeOffset.UtcNow >= expirationTime;
+ return GetUtcNow() >= expirationTime;
}
private void CleanupExpiredTasks(object? state)
diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
index e0fb3d1..7dafe34 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -5,12 +5,15 @@
net10.0;net9.0;net8.0;net472
enable
enable
+ true
false
true
ModelContextProtocol.Tests
$(NoWarn);NU1903;NU1902
+
+ $(DefineConstants);MCP_TEST_TIME_PROVIDER
@@ -27,6 +30,10 @@
+
+
+
+
@@ -34,6 +41,10 @@
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -43,6 +54,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
index fc858ee..5b9db45 100644
--- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
@@ -1,7 +1,9 @@
+using Microsoft.Extensions.Time.Testing;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
+using TestInMemoryMcpTaskStore = ModelContextProtocol.Tests.Internal.InMemoryMcpTaskStore;
namespace ModelContextProtocol.Tests.Server;
@@ -1031,4 +1033,141 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
// Assert
Assert.NotNull(task2);
}
+
+ [Fact]
+ public async Task ListTasksAsync_KeysetPaginationWorksWithIdenticalTimestamps()
+ {
+ // Arrange - Use a fake time provider to create tasks with identical timestamps
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ using var store = new TestInMemoryMcpTaskStore(
+ defaultTtl: null,
+ maxTtl: null,
+ pollInterval: null,
+ cleanupInterval: Timeout.InfiniteTimeSpan,
+ pageSize: 5,
+ maxTasks: null,
+ maxTasksPerSession: null,
+ timeProvider: fakeTime);
+
+ // Create 10 tasks - all with the EXACT same timestamp
+ var createdTasks = new List();
+ for (int i = 0; i < 10; i++)
+ {
+ var task = await store.CreateTaskAsync(
+ new McpTaskMetadata(),
+ new RequestId($"req-{i}"),
+ new JsonRpcRequest { Method = "test" },
+ null,
+ TestContext.Current.CancellationToken);
+ createdTasks.Add(task);
+ }
+
+ // Verify all tasks have the same CreatedAt timestamp
+ var firstTimestamp = createdTasks[0].CreatedAt;
+ Assert.All(createdTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt));
+
+ // Act - Get first page
+ var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert - First page should have 5 tasks
+ Assert.Equal(5, result1.Tasks.Length);
+ Assert.NotNull(result1.NextCursor);
+
+ // Get second page using cursor
+ var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert - Second page should have 5 tasks
+ Assert.Equal(5, result2.Tasks.Length);
+ Assert.Null(result2.NextCursor); // No more pages
+
+ // Verify no overlap between pages
+ var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet();
+ var page2Ids = result2.Tasks.Select(t => t.TaskId).ToHashSet();
+ Assert.Empty(page1Ids.Intersect(page2Ids));
+
+ // Verify we got all 10 tasks exactly once
+ var allReturnedIds = page1Ids.Union(page2Ids).ToHashSet();
+ var allCreatedIds = createdTasks.Select(t => t.TaskId).ToHashSet();
+ Assert.Equal(allCreatedIds, allReturnedIds);
+ }
+
+ [Fact]
+ public async Task ListTasksAsync_TasksCreatedAfterFirstPageWithSameTimestampAppearInSecondPage()
+ {
+ // Arrange - Use a fake time provider so we can control timestamps precisely
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ using var store = new TestInMemoryMcpTaskStore(
+ defaultTtl: null,
+ maxTtl: null,
+ pollInterval: null,
+ cleanupInterval: Timeout.InfiniteTimeSpan,
+ pageSize: 5,
+ maxTasks: null,
+ maxTasksPerSession: null,
+ timeProvider: fakeTime);
+
+ // Create initial 6 tasks - all with the same timestamp
+ // (6 so that first page has 5 and cursor points to task 5)
+ var initialTasks = new List();
+ for (int i = 0; i < 6; i++)
+ {
+ var task = await store.CreateTaskAsync(
+ new McpTaskMetadata(),
+ new RequestId($"req-initial-{i}"),
+ new JsonRpcRequest { Method = "test" },
+ null,
+ TestContext.Current.CancellationToken);
+ initialTasks.Add(task);
+ }
+
+ // Get first page - should have 5 tasks with a cursor
+ var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal(5, result1.Tasks.Length);
+ Assert.NotNull(result1.NextCursor);
+
+ // Now create 5 more tasks AFTER we got the first page cursor
+ // These tasks have the SAME timestamp as the cursor (time hasn't moved)
+ // Due to monotonic UUID v7 with counter, they should sort AFTER the cursor
+ var laterTasks = new List();
+ for (int i = 0; i < 5; i++)
+ {
+ var task = await store.CreateTaskAsync(
+ new McpTaskMetadata(),
+ new RequestId($"req-later-{i}"),
+ new JsonRpcRequest { Method = "test" },
+ null,
+ TestContext.Current.CancellationToken);
+ laterTasks.Add(task);
+ }
+
+ // Verify all tasks have the same timestamp
+ var allTasks = initialTasks.Concat(laterTasks).ToList();
+ var firstTimestamp = allTasks[0].CreatedAt;
+ Assert.All(allTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt));
+
+ // Get ALL remaining pages
+ var allSubsequentTasks = new List();
+ string? cursor = result1.NextCursor;
+ while (cursor != null)
+ {
+ var result = await store.ListTasksAsync(cursor: cursor, cancellationToken: TestContext.Current.CancellationToken);
+ allSubsequentTasks.AddRange(result.Tasks);
+ cursor = result.NextCursor;
+ }
+
+ // Verify no overlap between first page and subsequent
+ var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet();
+ var subsequentIds = allSubsequentTasks.Select(t => t.TaskId).ToHashSet();
+ Assert.Empty(page1Ids.Intersect(subsequentIds));
+
+ // Verify we got all tasks
+ var allReturnedIds = page1Ids.Union(subsequentIds).ToHashSet();
+ var allCreatedIds = allTasks.Select(t => t.TaskId).ToHashSet();
+ Assert.Equal(allCreatedIds, allReturnedIds);
+
+ // Most importantly: verify ALL the later tasks (created after first page) are surfaced
+ // in the subsequent pages
+ var laterTaskIds = laterTasks.Select(t => t.TaskId).ToHashSet();
+ Assert.Superset(laterTaskIds, subsequentIds);
+ }
}
From 5084a79068437fd3f7ac733570bc6a755aa8c836 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 17:45:39 -0500
Subject: [PATCH 018/320] Add ILoggerFactory to StreamableHttpServerTransport
(#1213)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub
---
.../StreamableHttpHandler.cs | 4 ++--
.../Server/StreamableHttpPostTransport.cs | 19 +++++++++++++++++--
.../Server/StreamableHttpServerTransport.cs | 16 ++++++++++++++--
3 files changed, 33 insertions(+), 6 deletions(-)
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index c50e513..22c8613 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -238,7 +238,7 @@ internal sealed class StreamableHttpHandler(
if (!HttpServerTransportOptions.Stateless)
{
sessionId = MakeNewSessionId();
- transport = new()
+ transport = new(loggerFactory)
{
SessionId = sessionId,
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
@@ -252,7 +252,7 @@ internal sealed class StreamableHttpHandler(
// If in the future we support resuming stateless requests, we should populate
// the event stream store and retry interval here as well.
sessionId = "";
- transport = new()
+ transport = new(loggerFactory)
{
Stateless = true,
};
diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
index f0f94c2..b6f6b66 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using System.Diagnostics;
using System.Net.ServerSentEvents;
@@ -10,7 +11,11 @@ namespace ModelContextProtocol.Server;
/// Handles processing the request/response body pairs for the Streamable HTTP transport.
/// This is typically used via .
///
-internal sealed class StreamableHttpPostTransport(StreamableHttpServerTransport parentTransport, Stream responseStream, CancellationToken sessionCancellationToken) : ITransport
+internal sealed partial class StreamableHttpPostTransport(
+ StreamableHttpServerTransport parentTransport,
+ Stream responseStream,
+ CancellationToken sessionCancellationToken,
+ ILogger logger) : ITransport
{
private readonly SemaphoreSlim _messageLock = new(1, 1);
private readonly TaskCompletionSource _httpResponseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -199,7 +204,14 @@ internal sealed class StreamableHttpPostTransport(StreamableHttpServerTransport
{
using var _ = await _messageLock.LockAsync().ConfigureAwait(false);
- await _storeSseWriter!.DisposeAsync().ConfigureAwait(false);
+ try
+ {
+ await _storeSseWriter!.DisposeAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ LogStoreStreamDisposalFailed(ex);
+ }
}
}
}
@@ -222,4 +234,7 @@ internal sealed class StreamableHttpPostTransport(StreamableHttpServerTransport
// Don't dispose the event stream writer here, as we may continue to write to the event store
// after disposal if there are pending messages.
}
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")]
+ private partial void LogStoreStreamDisposalFailed(Exception exception);
}
diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
index 55b6650..5822775 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
@@ -1,3 +1,5 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@@ -21,7 +23,7 @@ namespace ModelContextProtocol.Server;
/// such as when streaming completion results or providing progress updates during long-running operations.
///
///
-public sealed class StreamableHttpServerTransport : ITransport
+public sealed partial class StreamableHttpServerTransport : ITransport
{
///
/// The stream ID used for unsolicited messages sent via the standalone GET SSE stream.
@@ -35,6 +37,7 @@ public sealed class StreamableHttpServerTransport : ITransport
});
private readonly CancellationTokenSource _transportDisposedCts = new();
private readonly SemaphoreSlim _unsolicitedMessageLock = new(1, 1);
+ private readonly ILogger _logger;
private SseEventWriter? _httpSseWriter;
private ISseEventStreamWriter? _storeSseWriter;
@@ -42,6 +45,15 @@ public sealed class StreamableHttpServerTransport : ITransport
private bool _getHttpRequestStarted;
private bool _getHttpResponseCompleted;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Optional logger factory used for logging employed by the transport.
+ public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null)
+ {
+ _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance;
+ }
+
///
public string? SessionId { get; init; }
@@ -161,7 +173,7 @@ public sealed class StreamableHttpServerTransport : ITransport
Throw.IfNull(message);
Throw.IfNull(responseStream);
- var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token);
+ var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger);
using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_transportDisposedCts.Token, cancellationToken);
await using (postTransport.ConfigureAwait(false))
{
From 50d6d0b5709ae8631672d7dcea20c3ad6a1a231d Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 14:56:12 -0800
Subject: [PATCH 019/320] Fix XunitLoggerProvider crash when background work
logs after test disposal (#1229)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
tests/Common/Utils/XunitLoggerProvider.cs | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/tests/Common/Utils/XunitLoggerProvider.cs b/tests/Common/Utils/XunitLoggerProvider.cs
index 9e77c1f..2fb76fc 100644
--- a/tests/Common/Utils/XunitLoggerProvider.cs
+++ b/tests/Common/Utils/XunitLoggerProvider.cs
@@ -34,7 +34,20 @@ public class XunitLoggerProvider(ITestOutputHelper output) : ILoggerProvider
sb.Append(exception.ToString());
}
- output.WriteLine(sb.ToString());
+ try
+ {
+ output.WriteLine(sb.ToString());
+ }
+ catch (InvalidOperationException)
+ {
+ // Ignore exceptions from xUnit's TestOutputHelper when the test has already completed.
+ // Background work may continue logging after xUnit has disposed the test context.
+ }
+ catch (NullReferenceException)
+ {
+ // xUnit v3 may throw NullReferenceException in TestOutputHelper.QueueTestOutput()
+ // when the internal queue has been torn down after test completion.
+ }
}
public bool IsEnabled(LogLevel logLevel) => true;
From 6a4e4d27dcd0f6a6f645740d398ef6657a3bfc73 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 14:56:24 -0800
Subject: [PATCH 020/320] Skip .NET Framework tests on non-Windows platforms
(#1226)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.github/workflows/ci-build-test.yml | 9 ---------
Directory.Build.props | 6 ++++++
.../TestServerWithHosting/TestServerWithHosting.csproj | 2 +-
.../ModelContextProtocol.TestServer.csproj | 2 +-
.../ModelContextProtocol.Tests.csproj | 2 +-
5 files changed, 9 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml
index 37ee6ab..70e7687 100644
--- a/.github/workflows/ci-build-test.yml
+++ b/.github/workflows/ci-build-test.yml
@@ -47,15 +47,6 @@ jobs:
10.0.x
9.0.x
- # NetFX testing on non-Windows requires mono
- - name: 🔧 Setup Mono
- if: runner.os == 'Linux'
- run: sudo apt-get install -y mono-devel
-
- - name: 🔧 Setup Mono on macOS
- if: runner.os == 'macOS'
- run: brew install mono
-
- name: 🔧 Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
diff --git a/Directory.Build.props b/Directory.Build.props
index 390be36..f5cdd3a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -45,4 +45,10 @@
true
+
+
+
+ net10.0;net9.0;net8.0
+ $(DefaultTestTargetFrameworks);net472
+
diff --git a/samples/TestServerWithHosting/TestServerWithHosting.csproj b/samples/TestServerWithHosting/TestServerWithHosting.csproj
index 7967395..9eca173 100644
--- a/samples/TestServerWithHosting/TestServerWithHosting.csproj
+++ b/samples/TestServerWithHosting/TestServerWithHosting.csproj
@@ -2,7 +2,7 @@
Exe
- net10.0;net9.0;net8.0;net472
+ $(DefaultTestTargetFrameworks)
enable
enable
true
diff --git a/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj b/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj
index c4b39bb..229c57d 100644
--- a/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj
+++ b/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj
@@ -2,7 +2,7 @@
Exe
- net10.0;net9.0;net8.0;net472
+ $(DefaultTestTargetFrameworks)
enable
enable
TestServer
diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
index 7dafe34..1068e56 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -2,7 +2,7 @@
Exe
- net10.0;net9.0;net8.0;net472
+ $(DefaultTestTargetFrameworks)
enable
enable
true
From 6224f30a33ee5bafc107a7a67143f4a2f36b7636 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 14:56:45 -0800
Subject: [PATCH 021/320] Suppress TimeProvider.Testing net472 warning (#1227)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../ModelContextProtocol.Tests.csproj | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
index 1068e56..3aba865 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -21,6 +21,11 @@
false
+
+
+ true
+
+
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
From 0c35f8f33341035b1fce4ebeede197deaf8f0b50 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 2 Feb 2026 17:10:59 +0200
Subject: [PATCH 024/320] Bump the other-testing group with 1 update (#1233)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b34eab3..425b96d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -93,6 +93,6 @@
-
+
\ No newline at end of file
From 712068b1610b7fd8acf4a844a14ce08d00ee3d19 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 2 Feb 2026 10:15:20 -0500
Subject: [PATCH 025/320] Seal public Protocol reference types to prevent
external inheritance (#1232)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Protocol/ContentBlock.cs | 2 +-
.../Protocol/ElicitRequestParams.cs | 24 ++++---------------
.../Protocol/JsonRpcMessageContext.cs | 2 +-
.../Protocol/ResourceContents.cs | 2 +-
.../Protocol/EnumSchemaTests.cs | 14 +++++------
.../PrimitiveSchemaDefinitionTests.cs | 2 +-
6 files changed, 16 insertions(+), 30 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
index 04c98bc..c854787 100644
--- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
@@ -69,7 +69,7 @@ public abstract class ContentBlock
/// Provides a polymorphic converter for the class that doesn't require
/// setting explicitly.
[EditorBrowsable(EditorBrowsableState.Never)]
- public class Converter : JsonConverter
+ public sealed class Converter : JsonConverter
{
///
public override ContentBlock? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
index 0203edd..2e50824 100644
--- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
@@ -103,7 +103,7 @@ public sealed class ElicitRequestParams : RequestParams
public McpTaskMetadata? Task { get; set; }
/// Represents a request schema used in a form mode elicitation request.
- public class RequestSchema
+ public sealed class RequestSchema
{
/// Gets the type of the schema.
/// This value is always "object".
@@ -161,7 +161,7 @@ public sealed class ElicitRequestParams : RequestParams
/// Provides a polymorphic converter for the class that doesn't require
/// setting explicitly.
[EditorBrowsable(EditorBrowsableState.Never)]
- public class Converter : JsonConverter
+ public sealed class Converter : JsonConverter
{
///
public override PrimitiveSchemaDefinition? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
@@ -315,11 +315,9 @@ public sealed class ElicitRequestParams : RequestParams
{
if (enumNames is not null)
{
- // EnumSchema is deprecated but supported for backward compatibility.
- // Use the EnumSchema class, which is an alias for LegacyTitledEnumSchema,
- // to ensure backward compatibility with existing code relying on that type.
+ // LegacyTitledEnumSchema is deprecated but supported for backward compatibility.
#pragma warning disable MCP9001
- psd = new EnumSchema
+ psd = new LegacyTitledEnumSchema
#pragma warning restore MCP9001
{
Enum = enumValues,
@@ -996,24 +994,12 @@ public sealed class ElicitRequestParams : RequestParams
///
/// Represents a legacy schema for an enum type with enumNames.
- /// This is a compatibility alias for .
///
///
/// This schema is deprecated in favor of .
///
[Obsolete(Obsoletions.LegacyTitledEnumSchema_Message, DiagnosticId = Obsoletions.LegacyTitledEnumSchema_DiagnosticId, UrlFormat = Obsoletions.LegacyTitledEnumSchema_Url)]
- public sealed class EnumSchema : LegacyTitledEnumSchema
- {
- }
-
- ///
- /// Represents a legacy schema for an enum type with enumNames.
- ///
- ///
- /// This schema is deprecated in favor of .
- ///
- [Obsolete(Obsoletions.LegacyTitledEnumSchema_Message, DiagnosticId = Obsoletions.LegacyTitledEnumSchema_DiagnosticId, UrlFormat = Obsoletions.LegacyTitledEnumSchema_Url)]
- public class LegacyTitledEnumSchema : PrimitiveSchemaDefinition
+ public sealed class LegacyTitledEnumSchema : PrimitiveSchemaDefinition
{
///
[JsonPropertyName("type")]
diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
index 38d798d..b9c9a24 100644
--- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
+++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
@@ -12,7 +12,7 @@ namespace ModelContextProtocol.Protocol;
/// but is not serialized as part of the JSON-RPC payload. This includes transport references, execution context,
/// and authenticated user information.
///
-public class JsonRpcMessageContext
+public sealed class JsonRpcMessageContext
{
///
/// Gets or sets the transport the was received on or should be sent over.
diff --git a/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs b/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs
index 9c295a1..0948c42 100644
--- a/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs
@@ -61,7 +61,7 @@ public abstract class ResourceContents
/// Provides a for .
///
[EditorBrowsable(EditorBrowsableState.Never)]
- public class Converter : JsonConverter
+ public sealed class Converter : JsonConverter
{
///
public override ResourceContents? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
diff --git a/tests/ModelContextProtocol.Tests/Protocol/EnumSchemaTests.cs b/tests/ModelContextProtocol.Tests/Protocol/EnumSchemaTests.cs
index f0addb9..95220d6 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/EnumSchemaTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/EnumSchemaTests.cs
@@ -298,7 +298,7 @@ public class EnumSchemaTests
// Assert
Assert.NotNull(deserialized);
- var result = Assert.IsType(deserialized);
+ var result = Assert.IsType(deserialized);
Assert.Equal("string", result.Type);
Assert.Equal("Environment", result.Title);
Assert.Equal("Deployment environment", result.Description);
@@ -309,10 +309,10 @@ public class EnumSchemaTests
}
[Fact]
- public void EnumSchema_Serializes_Correctly()
+ public void LegacyTitledEnumSchema_Direct_Serializes_Correctly()
{
// Arrange
- var schema = new ElicitRequestParams.EnumSchema
+ var schema = new ElicitRequestParams.LegacyTitledEnumSchema
{
Title = "Environment",
Description = "Deployment environment",
@@ -327,7 +327,7 @@ public class EnumSchemaTests
// Assert
Assert.NotNull(deserialized);
- var result = Assert.IsType(deserialized);
+ var result = Assert.IsType(deserialized);
Assert.Equal("string", result.Type);
Assert.Equal("Environment", result.Title);
Assert.Equal("Deployment environment", result.Description);
@@ -338,9 +338,9 @@ public class EnumSchemaTests
}
[Fact]
- public void Enum_WithEnumNames_Deserializes_As_EnumSchema()
+ public void Enum_WithEnumNames_Deserializes_As_LegacyTitledEnumSchema()
{
- // Arrange - JSON with enumNames should deserialize as (deprecated) EnumSchema
+ // Arrange - JSON with enumNames should deserialize as (deprecated) LegacyTitledEnumSchema
string json = """
{
"type": "string",
@@ -356,7 +356,7 @@ public class EnumSchemaTests
// Assert
Assert.NotNull(deserialized);
- var result = Assert.IsType(deserialized);
+ var result = Assert.IsType(deserialized);
Assert.Equal("string", result.Type);
Assert.Equal("Environment", result.Title);
Assert.Equal("Deployment environment", result.Description);
diff --git a/tests/ModelContextProtocol.Tests/Protocol/PrimitiveSchemaDefinitionTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PrimitiveSchemaDefinitionTests.cs
index fc83ee5..d7bea3f 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/PrimitiveSchemaDefinitionTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/PrimitiveSchemaDefinitionTests.cs
@@ -283,7 +283,7 @@ public static class PrimitiveSchemaDefinitionTests
json,
McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
- var enumSchema = Assert.IsType(result);
+ var enumSchema = Assert.IsType(result);
Assert.Equal("string", enumSchema.Type);
Assert.Equal(2, enumSchema.Enum.Count);
Assert.Contains("option1", enumSchema.Enum);
From d5c6b0d7f9d72a396850fcee06c179a5aaeb01af Mon Sep 17 00:00:00 2001
From: Mackinnon Buck
Date: Tue, 3 Feb 2026 15:48:57 -0800
Subject: [PATCH 026/320] Add `DistributedCacheEventStreamStore` (#1136)
Co-authored-by: Stephen Halter
---
Directory.Packages.props | 2 +
.../ModelContextProtocol.csproj | 1 +
.../DistributedCacheEventIdFormatter.cs | 117 ++
.../DistributedCacheEventStreamStore.cs | 401 ++++
...ributedCacheEventStreamStoreJsonContext.cs | 60 +
...DistributedCacheEventStreamStoreOptions.cs | 51 +
...ibutedCacheResumabilityIntegrationTests.cs | 49 +
...delContextProtocol.AspNetCore.Tests.csproj | 1 +
.../ResumabilityIntegrationTests.cs | 556 +-----
.../ResumabilityIntegrationTestsBase.cs | 560 ++++++
.../ModelContextProtocol.Tests.csproj | 6 +
.../DistributedCacheEventStreamStoreTests.cs | 1752 +++++++++++++++++
12 files changed, 3045 insertions(+), 511 deletions(-)
create mode 100644 src/ModelContextProtocol/Server/DistributedCacheEventIdFormatter.cs
create mode 100644 src/ModelContextProtocol/Server/DistributedCacheEventStreamStore.cs
create mode 100644 src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreJsonContext.cs
create mode 100644 src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs
create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/DistributedCacheResumabilityIntegrationTests.cs
create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs
create mode 100644 tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 425b96d..044adfd 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,6 +14,8 @@
+
+
diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj
index fcf549c..0477ca5 100644
--- a/src/ModelContextProtocol/ModelContextProtocol.csproj
+++ b/src/ModelContextProtocol/ModelContextProtocol.csproj
@@ -24,6 +24,7 @@
+
diff --git a/src/ModelContextProtocol/Server/DistributedCacheEventIdFormatter.cs b/src/ModelContextProtocol/Server/DistributedCacheEventIdFormatter.cs
new file mode 100644
index 0000000..ee03b34
--- /dev/null
+++ b/src/ModelContextProtocol/Server/DistributedCacheEventIdFormatter.cs
@@ -0,0 +1,117 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+// This is a shared source file included in both ModelContextProtocol and the test project.
+// Do not reference symbols internal to the core project, as they won't be available in tests.
+#if NET
+using System.Buffers;
+using System.Buffers.Text;
+using System.Diagnostics.CodeAnalysis;
+
+#endif
+using System.Text;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Provides methods for formatting and parsing event IDs used by .
+///
+///
+/// Event IDs are formatted as "{base64(sessionId)}:{base64(streamId)}:{sequence}".
+///
+internal static class DistributedCacheEventIdFormatter
+{
+ private const char Separator = ':';
+
+ ///
+ /// Formats session ID, stream ID, and sequence number into an event ID string.
+ ///
+ public static string Format(string sessionId, string streamId, long sequence)
+ {
+ // Base64-encode session and stream IDs so the event ID can be parsed
+ // even if the original IDs contain the ':' separator character
+ var sessionBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(sessionId));
+ var streamBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(streamId));
+ return $"{sessionBase64}{Separator}{streamBase64}{Separator}{sequence}";
+ }
+
+ ///
+ /// Attempts to parse an event ID into its component parts.
+ ///
+ public static bool TryParse(string eventId, out string sessionId, out string streamId, out long sequence)
+ {
+ sessionId = string.Empty;
+ streamId = string.Empty;
+ sequence = 0;
+
+#if NET
+ ReadOnlySpan eventIdSpan = eventId.AsSpan();
+ Span partRanges = stackalloc Range[4];
+ int rangeCount = eventIdSpan.Split(partRanges, Separator);
+ if (rangeCount != 3)
+ {
+ return false;
+ }
+
+ try
+ {
+ ReadOnlySpan sessionBase64 = eventIdSpan[partRanges[0]];
+ ReadOnlySpan streamBase64 = eventIdSpan[partRanges[1]];
+ ReadOnlySpan sequenceSpan = eventIdSpan[partRanges[2]];
+
+ if (!TryDecodeBase64ToString(sessionBase64, out sessionId!) ||
+ !TryDecodeBase64ToString(streamBase64, out streamId!))
+ {
+ return false;
+ }
+
+ return long.TryParse(sequenceSpan, out sequence);
+ }
+ catch
+ {
+ return false;
+ }
+#else
+ var parts = eventId.Split(Separator);
+ if (parts.Length != 3)
+ {
+ return false;
+ }
+
+ try
+ {
+ sessionId = Encoding.UTF8.GetString(Convert.FromBase64String(parts[0]));
+ streamId = Encoding.UTF8.GetString(Convert.FromBase64String(parts[1]));
+ return long.TryParse(parts[2], out sequence);
+ }
+ catch
+ {
+ return false;
+ }
+#endif
+ }
+
+#if NET
+ private static bool TryDecodeBase64ToString(ReadOnlySpan base64Chars, [NotNullWhen(true)] out string? result)
+ {
+ // Use a single buffer: base64 chars are ASCII (1:1 with UTF8 bytes),
+ // and decoded data is always smaller than encoded, so we can decode in-place.
+ int bufferLength = base64Chars.Length;
+ Span buffer = bufferLength <= 256
+ ? stackalloc byte[bufferLength]
+ : new byte[bufferLength];
+
+ Encoding.UTF8.GetBytes(base64Chars, buffer);
+
+ OperationStatus status = Base64.DecodeFromUtf8InPlace(buffer, out int bytesWritten);
+ if (status != OperationStatus.Done)
+ {
+ result = null;
+ return false;
+ }
+
+ result = Encoding.UTF8.GetString(buffer[..bytesWritten]);
+ return true;
+ }
+#endif
+}
diff --git a/src/ModelContextProtocol/Server/DistributedCacheEventStreamStore.cs b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStore.cs
new file mode 100644
index 0000000..4a71b94
--- /dev/null
+++ b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStore.cs
@@ -0,0 +1,401 @@
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ModelContextProtocol.Protocol;
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// An implementation backed by .
+///
+///
+///
+/// This implementation stores SSE events in a distributed cache, enabling resumability across
+/// multiple server instances. Event IDs are encoded with session, stream, and sequence information
+/// to allow efficient retrieval of events after a given point.
+///
+///
+/// The writer maintains in-memory state for sequence number generation, as there is guaranteed
+/// to be only one writer per stream. Readers may be created from separate processes.
+///
+///
+public sealed partial class DistributedCacheEventStreamStore : ISseEventStreamStore
+{
+ private readonly IDistributedCache _cache;
+ private readonly DistributedCacheEventStreamStoreOptions _options;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The distributed cache to use for storage.
+ /// Optional configuration options for the store.
+ /// Optional logger for diagnostic output.
+ public DistributedCacheEventStreamStore(IDistributedCache cache, DistributedCacheEventStreamStoreOptions? options = null, ILogger? logger = null)
+ {
+ Throw.IfNull(cache);
+ _cache = cache;
+ _options = options ?? new();
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ public ValueTask CreateStreamAsync(SseEventStreamOptions options, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(options);
+ LogStreamCreated(options.SessionId, options.StreamId, options.Mode);
+ var writer = new DistributedCacheEventStreamWriter(_cache, options.SessionId, options.StreamId, options.Mode, _options, _logger);
+ return new ValueTask(writer);
+ }
+
+ ///
+ public async ValueTask GetStreamReaderAsync(string lastEventId, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(lastEventId);
+
+ // Parse the event ID to get session, stream, and sequence information
+ if (!DistributedCacheEventIdFormatter.TryParse(lastEventId, out var sessionId, out var streamId, out var sequence))
+ {
+ LogEventIdParsingFailed(lastEventId);
+ return null;
+ }
+
+ // Check if the stream exists by looking for its metadata
+ var metadataKey = CacheKeys.StreamMetadata(sessionId, streamId);
+ var metadataBytes = await _cache.GetAsync(metadataKey, cancellationToken).ConfigureAwait(false);
+ if (metadataBytes is null)
+ {
+ LogStreamMetadataNotFound(sessionId, streamId);
+ return null;
+ }
+
+ var metadata = JsonSerializer.Deserialize(metadataBytes, DistributedCacheEventStreamStoreJsonUtilities.StreamMetadataJsonTypeInfo);
+ if (metadata is null)
+ {
+ LogStreamMetadataDeserializationFailed(sessionId, streamId);
+ return null;
+ }
+
+ var startSequence = sequence + 1;
+ LogStreamReaderCreated(sessionId, streamId, startSequence, metadata.LastSequence);
+ return new DistributedCacheEventStreamReader(_cache, sessionId, streamId, startSequence, metadata, _options, _logger);
+ }
+
+ ///
+ /// Provides methods for generating cache keys.
+ ///
+ ///
+ /// Cache keys are versioned to allow format changes without conflicts with existing entries.
+ /// When the cache format changes, increment to invalidate old entries.
+ ///
+ internal static class CacheKeys
+ {
+ ///
+ /// The current cache key version. Increment this when changing the cache format
+ /// to ensure old entries are ignored.
+ ///
+ private const string Version = "v1";
+ private const string Prefix = $"mcp:sse:{Version}:";
+
+ public static string StreamMetadata(string sessionId, string streamId)
+ {
+ var sessionIdBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(sessionId));
+ var streamIdBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(streamId));
+ return $"{Prefix}meta:{sessionIdBase64}:{streamIdBase64}";
+ }
+
+ public static string Event(string eventId)
+ => $"{Prefix}event:{eventId}";
+ }
+
+ ///
+ /// Metadata about a stream stored in the cache.
+ ///
+ internal sealed class StreamMetadata
+ {
+ public SseEventStreamMode Mode { get; set; }
+ public bool IsCompleted { get; set; }
+ public long LastSequence { get; set; }
+ }
+
+ ///
+ /// Serialized representation of an SSE event stored in the cache.
+ ///
+ internal sealed class StoredEvent
+ {
+ public string? EventType { get; set; }
+ public string? EventId { get; set; }
+ public int? ReconnectionIntervalMs { get; set; }
+ public JsonRpcMessage? Data { get; set; }
+ }
+
+ private sealed partial class DistributedCacheEventStreamWriter : ISseEventStreamWriter
+ {
+ private readonly IDistributedCache _cache;
+ private readonly string _sessionId;
+ private readonly string _streamId;
+ private SseEventStreamMode _mode;
+ private readonly DistributedCacheEventStreamStoreOptions _options;
+ private readonly ILogger _logger;
+ private long _sequence;
+ private bool _disposed;
+
+ public DistributedCacheEventStreamWriter(
+ IDistributedCache cache,
+ string sessionId,
+ string streamId,
+ SseEventStreamMode mode,
+ DistributedCacheEventStreamStoreOptions options,
+ ILogger logger)
+ {
+ _cache = cache;
+ _sessionId = sessionId;
+ _streamId = streamId;
+ _mode = mode;
+ _options = options;
+ _logger = logger;
+ }
+
+ public async ValueTask SetModeAsync(SseEventStreamMode mode, CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+
+ LogStreamModeChanged(_sessionId, _streamId, mode);
+ _mode = mode;
+ await UpdateMetadataAsync(isCompleted: false, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask> WriteEventAsync(SseItem sseItem, CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+
+ // Skip if already has an event ID
+ if (sseItem.EventId is not null)
+ {
+ LogEventAlreadyHasId(_sessionId, _streamId, sseItem.EventId);
+ return sseItem;
+ }
+
+ // Generate a new sequence number and event ID
+ var sequence = Interlocked.Increment(ref _sequence);
+ var eventId = DistributedCacheEventIdFormatter.Format(_sessionId, _streamId, sequence);
+ var newItem = sseItem with { EventId = eventId };
+
+ // Store the event in the cache
+ var storedEvent = new StoredEvent
+ {
+ EventType = newItem.EventType,
+ EventId = eventId,
+ ReconnectionIntervalMs = newItem.ReconnectionInterval.HasValue
+ ? (int)newItem.ReconnectionInterval.Value.TotalMilliseconds
+ : null,
+ Data = newItem.Data,
+ };
+
+ var eventBytes = JsonSerializer.SerializeToUtf8Bytes(storedEvent, DistributedCacheEventStreamStoreJsonUtilities.StoredEventJsonTypeInfo);
+ var eventKey = CacheKeys.Event(eventId);
+
+ await _cache.SetAsync(eventKey, eventBytes, new DistributedCacheEntryOptions
+ {
+ SlidingExpiration = _options.EventSlidingExpiration,
+ AbsoluteExpirationRelativeToNow = _options.EventAbsoluteExpiration,
+ }, cancellationToken).ConfigureAwait(false);
+
+ // Update metadata with the latest sequence
+ await UpdateMetadataAsync(isCompleted: false, cancellationToken).ConfigureAwait(false);
+
+ LogEventWritten(_sessionId, _streamId, eventId, sequence);
+ return newItem;
+ }
+
+ private async ValueTask UpdateMetadataAsync(bool isCompleted, CancellationToken cancellationToken)
+ {
+ var metadata = new StreamMetadata
+ {
+ Mode = _mode,
+ IsCompleted = isCompleted,
+ LastSequence = Interlocked.Read(ref _sequence),
+ };
+
+ var metadataBytes = JsonSerializer.SerializeToUtf8Bytes(metadata, DistributedCacheEventStreamStoreJsonUtilities.StreamMetadataJsonTypeInfo);
+ var metadataKey = CacheKeys.StreamMetadata(_sessionId, _streamId);
+
+ await _cache.SetAsync(metadataKey, metadataBytes, new DistributedCacheEntryOptions
+ {
+ SlidingExpiration = _options.MetadataSlidingExpiration,
+ AbsoluteExpirationRelativeToNow = _options.MetadataAbsoluteExpiration,
+ }, cancellationToken).ConfigureAwait(false);
+ }
+
+ private void ThrowIfDisposed()
+ {
+#if NET
+ ObjectDisposedException.ThrowIf(_disposed, this);
+#else
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(DistributedCacheEventStreamWriter));
+ }
+#endif
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ // Mark the stream as completed in the metadata
+ await UpdateMetadataAsync(isCompleted: true, CancellationToken.None).ConfigureAwait(false);
+ LogStreamWriterDisposed(_sessionId, _streamId, Interlocked.Read(ref _sequence));
+ }
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Stream mode changed for session '{SessionId}', stream '{StreamId}' to {Mode}.")]
+ private partial void LogStreamModeChanged(string sessionId, string streamId, SseEventStreamMode mode);
+
+ [LoggerMessage(Level = LogLevel.Trace, Message = "Event already has ID '{EventId}' for session '{SessionId}', stream '{StreamId}'. Skipping ID generation.")]
+ private partial void LogEventAlreadyHasId(string sessionId, string streamId, string eventId);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Event written to session '{SessionId}', stream '{StreamId}' with ID '{EventId}' (sequence {Sequence}).")]
+ private partial void LogEventWritten(string sessionId, string streamId, string eventId, long sequence);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Stream writer disposed for session '{SessionId}', stream '{StreamId}'. Total events written: {TotalEvents}.")]
+ private partial void LogStreamWriterDisposed(string sessionId, string streamId, long totalEvents);
+ }
+
+ private sealed partial class DistributedCacheEventStreamReader : ISseEventStreamReader
+ {
+ private readonly IDistributedCache _cache;
+ private readonly long _startSequence;
+ private readonly StreamMetadata _initialMetadata;
+ private readonly DistributedCacheEventStreamStoreOptions _options;
+ private readonly ILogger _logger;
+
+ public DistributedCacheEventStreamReader(
+ IDistributedCache cache,
+ string sessionId,
+ string streamId,
+ long startSequence,
+ StreamMetadata initialMetadata,
+ DistributedCacheEventStreamStoreOptions options,
+ ILogger logger)
+ {
+ _cache = cache;
+ SessionId = sessionId;
+ StreamId = streamId;
+ _startSequence = startSequence;
+ _initialMetadata = initialMetadata;
+ _options = options;
+ _logger = logger;
+ }
+
+ public string SessionId { get; }
+ public string StreamId { get; }
+
+ public async IAsyncEnumerable> ReadEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Start from the sequence after the last received event
+ var currentSequence = _startSequence;
+
+ // Use the initial metadata passed to the constructor for the first read.
+ var lastSequence = _initialMetadata.LastSequence;
+ var isCompleted = _initialMetadata.IsCompleted;
+ var mode = _initialMetadata.Mode;
+
+ LogReadingEventsStarted(SessionId, StreamId, _startSequence, lastSequence);
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ // Read all available events from currentSequence + 1 to lastSequence
+ for (; currentSequence <= lastSequence; currentSequence++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var eventId = DistributedCacheEventIdFormatter.Format(SessionId, StreamId, currentSequence);
+ var eventKey = CacheKeys.Event(eventId);
+ var eventBytes = await _cache.GetAsync(eventKey, cancellationToken).ConfigureAwait(false)
+ ?? throw new McpException($"SSE event with ID '{eventId}' was not found in the cache. The event may have expired.");
+
+ var storedEvent = JsonSerializer.Deserialize(eventBytes, DistributedCacheEventStreamStoreJsonUtilities.StoredEventJsonTypeInfo);
+ if (storedEvent is not null)
+ {
+ LogEventRead(SessionId, StreamId, eventId, currentSequence);
+ yield return new SseItem(storedEvent.Data, storedEvent.EventType)
+ {
+ EventId = storedEvent.EventId,
+ ReconnectionInterval = storedEvent.ReconnectionIntervalMs.HasValue
+ ? TimeSpan.FromMilliseconds(storedEvent.ReconnectionIntervalMs.Value)
+ : null,
+ };
+ }
+ }
+
+ // If in polling mode, stop after returning currently available events
+ if (mode == SseEventStreamMode.Polling)
+ {
+ LogReadingEventsCompletedPolling(SessionId, StreamId, currentSequence - 1);
+ yield break;
+ }
+
+ // If the stream is completed and we've read all events, stop
+ if (isCompleted)
+ {
+ LogReadingEventsCompletedStreamEnded(SessionId, StreamId, currentSequence - 1);
+ yield break;
+ }
+
+ // Wait before polling again for new events
+ LogWaitingForNewEvents(SessionId, StreamId, _options.StreamReaderPollingInterval);
+ await Task.Delay(_options.StreamReaderPollingInterval, cancellationToken).ConfigureAwait(false);
+
+ // Refresh metadata to get the latest sequence and completion status
+ var metadataKey = CacheKeys.StreamMetadata(SessionId, StreamId);
+ var metadataBytes = await _cache.GetAsync(metadataKey, cancellationToken).ConfigureAwait(false)
+ ?? throw new McpException($"Stream metadata for session '{SessionId}' and stream '{StreamId}' was not found in the cache. The metadata may have expired.");
+
+ var currentMetadata = JsonSerializer.Deserialize(metadataBytes, DistributedCacheEventStreamStoreJsonUtilities.StreamMetadataJsonTypeInfo)
+ ?? throw new McpException($"Stream metadata for session '{SessionId}' and stream '{StreamId}' could not be deserialized.");
+
+ lastSequence = currentMetadata.LastSequence;
+ isCompleted = currentMetadata.IsCompleted;
+ mode = currentMetadata.Mode;
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Starting to read events for session '{SessionId}', stream '{StreamId}' starting at sequence {StartSequence}. Last available sequence: {LastSequence}.")]
+ private partial void LogReadingEventsStarted(string sessionId, string streamId, long startSequence, long lastSequence);
+
+ [LoggerMessage(Level = LogLevel.Trace, Message = "Event read from session '{SessionId}', stream '{StreamId}' with ID '{EventId}' (sequence {Sequence}).")]
+ private partial void LogEventRead(string sessionId, string streamId, string eventId, long sequence);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Reading events completed for session '{SessionId}', stream '{StreamId}' in polling mode. Last sequence read: {LastSequence}.")]
+ private partial void LogReadingEventsCompletedPolling(string sessionId, string streamId, long lastSequence);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Reading events completed for session '{SessionId}', stream '{StreamId}' as stream has ended. Last sequence read: {LastSequence}.")]
+ private partial void LogReadingEventsCompletedStreamEnded(string sessionId, string streamId, long lastSequence);
+
+ [LoggerMessage(Level = LogLevel.Trace, Message = "Waiting for new events on session '{SessionId}', stream '{StreamId}'. Polling interval: {PollingInterval}.")]
+ private partial void LogWaitingForNewEvents(string sessionId, string streamId, TimeSpan pollingInterval);
+ }
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Stream created for session '{SessionId}', stream '{StreamId}' with mode {Mode}.")]
+ private partial void LogStreamCreated(string sessionId, string streamId, SseEventStreamMode mode);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Stream reader created for session '{SessionId}', stream '{StreamId}' starting at sequence {StartSequence}. Last available sequence: {LastSequence}.")]
+ private partial void LogStreamReaderCreated(string sessionId, string streamId, long startSequence, long lastSequence);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to parse event ID '{EventId}'. Unable to create stream reader.")]
+ private partial void LogEventIdParsingFailed(string eventId);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Stream metadata not found for session '{SessionId}', stream '{StreamId}'.")]
+ private partial void LogStreamMetadataNotFound(string sessionId, string streamId);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to deserialize stream metadata for session '{SessionId}', stream '{StreamId}'.")]
+ private partial void LogStreamMetadataDeserializationFailed(string sessionId, string streamId);
+}
diff --git a/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreJsonContext.cs b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreJsonContext.cs
new file mode 100644
index 0000000..b86f9e3
--- /dev/null
+++ b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreJsonContext.cs
@@ -0,0 +1,60 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Provides JSON serialization utilities for types.
+///
+///
+/// This class provides source-generated serialization for the internal types used by
+/// to persist SSE events and stream metadata.
+/// It combines with to support the full object
+/// graph including and its derived types.
+///
+internal static partial class DistributedCacheEventStreamStoreJsonUtilities
+{
+ ///
+ /// Gets the instance for serializing distributed cache event stream store types.
+ ///
+ ///
+ /// This options instance combines the source-generated context for
+ /// and with
+ /// to support the full object graph including types.
+ ///
+ public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
+
+ ///
+ /// Gets the for .
+ ///
+ public static JsonTypeInfo StreamMetadataJsonTypeInfo { get; } =
+ (JsonTypeInfo)DefaultOptions.GetTypeInfo(typeof(DistributedCacheEventStreamStore.StreamMetadata));
+
+ ///
+ /// Gets the for .
+ ///
+ public static JsonTypeInfo StoredEventJsonTypeInfo { get; } =
+ (JsonTypeInfo)DefaultOptions.GetTypeInfo(typeof(DistributedCacheEventStreamStore.StoredEvent));
+
+ private static JsonSerializerOptions CreateDefaultOptions()
+ {
+ // Copy the configuration from McpJsonUtilities.DefaultOptions.
+ var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions);
+
+ // Add our source-generated context for StreamMetadata and StoredEvent.
+ options.TypeInfoResolverChain.Insert(0, JsonContext.Default);
+
+ options.MakeReadOnly();
+ return options;
+ }
+
+ [JsonSourceGenerationOptions(
+ JsonSerializerDefaults.Web,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ GenerationMode = JsonSourceGenerationMode.Metadata)]
+ [JsonSerializable(typeof(DistributedCacheEventStreamStore.StreamMetadata))]
+ [JsonSerializable(typeof(DistributedCacheEventStreamStore.StoredEvent))]
+ private sealed partial class JsonContext : JsonSerializerContext;
+}
diff --git a/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs
new file mode 100644
index 0000000..e1542ca
--- /dev/null
+++ b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs
@@ -0,0 +1,51 @@
+namespace ModelContextProtocol.Server;
+
+///
+/// Configuration options for .
+///
+public sealed class DistributedCacheEventStreamStoreOptions
+{
+ ///
+ /// Gets or sets the sliding expiration for individual events in the cache.
+ ///
+ ///
+ /// Events are refreshed on each access. If an event is not accessed within this
+ /// time period, it may be evicted from the cache.
+ ///
+ public TimeSpan? EventSlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// Gets or sets the absolute expiration for individual events in the cache.
+ ///
+ ///
+ /// Events will be evicted from the cache after this time period, regardless of access.
+ ///
+ public TimeSpan? EventAbsoluteExpiration { get; set; } = TimeSpan.FromHours(2);
+
+ ///
+ /// Gets or sets the sliding expiration for stream metadata in the cache.
+ ///
+ ///
+ /// Stream metadata includes mode and completion status. This should typically be
+ /// set to a longer duration than event expiration to allow for resumability.
+ ///
+ public TimeSpan? MetadataSlidingExpiration { get; set; } = TimeSpan.FromHours(1);
+
+ ///
+ /// Gets or sets the absolute expiration for stream metadata in the cache.
+ ///
+ ///
+ /// Stream metadata will be evicted from the cache after this time period, regardless of access.
+ ///
+ public TimeSpan? MetadataAbsoluteExpiration { get; set; } = TimeSpan.FromHours(4);
+
+ ///
+ /// Gets or sets the interval between polling attempts when a stream reader is waiting for new events
+ /// in the default mode.
+ ///
+ ///
+ /// This only affects stream readers. A shorter interval provides lower latency for new events
+ /// but increases cache access frequency.
+ ///
+ public TimeSpan StreamReaderPollingInterval { get; set; } = TimeSpan.FromSeconds(1);
+}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DistributedCacheResumabilityIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DistributedCacheResumabilityIntegrationTests.cs
new file mode 100644
index 0000000..8ec31ea
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/DistributedCacheResumabilityIntegrationTests.cs
@@ -0,0 +1,49 @@
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Server;
+
+namespace ModelContextProtocol.AspNetCore.Tests;
+
+///
+/// Integration tests for SSE resumability using .
+///
+///
+///
+/// This class runs the same resumability tests as but
+/// using the production implementation backed by
+/// an in-memory distributed cache.
+///
+///
+/// These tests verify that the distributed cache implementation correctly stores and retrieves
+/// events for resumability, including across simulated disconnections.
+///
+///
+public class DistributedCacheResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) : ResumabilityIntegrationTestsBase(testOutputHelper)
+{
+ private MemoryDistributedCache? _cache;
+
+ ///
+ protected override ValueTask CreateEventStreamStoreAsync()
+ {
+ // Create a new in-memory distributed cache for each test
+ _cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
+
+ // Configure the store with shorter expiration times suitable for testing
+ var options = new DistributedCacheEventStreamStoreOptions
+ {
+ // Use shorter polling interval for faster test execution
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50),
+
+ // Use shorter expiration times for tests
+ EventSlidingExpiration = TimeSpan.FromMinutes(5),
+ EventAbsoluteExpiration = TimeSpan.FromMinutes(10),
+ MetadataSlidingExpiration = TimeSpan.FromMinutes(5),
+ MetadataAbsoluteExpiration = TimeSpan.FromMinutes(10),
+ };
+
+ var store = new DistributedCacheEventStreamStore(_cache, options, LoggerFactory.CreateLogger());
+ return new ValueTask(store);
+ }
+}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj
index 934a81c..02ce7de 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj
@@ -31,6 +31,7 @@
all
+
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs
index 5f83e05..ee479c1 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs
@@ -1,70 +1,73 @@
-using System.ComponentModel;
-using System.Diagnostics;
-using System.Net;
using System.Net.ServerSentEvents;
-using System.Text;
-using System.Text.Json.Nodes;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
-using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
-using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.AspNetCore.Tests;
///
-/// Integration tests for SSE resumability with full client-server flow.
-/// These tests use McpClient for end-to-end testing and only use raw HTTP
-/// for SSE format verification where McpClient abstracts away the details.
+/// Integration tests for SSE resumability using the in-memory .
///
-public class ResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
+///
+///
+/// This class extends to add assertions specific to
+/// , such as verifying event counts and stored data.
+///
+///
+/// Tests that don't require -specific assertions are inherited
+/// from the base class.
+///
+///
+public class ResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) : ResumabilityIntegrationTestsBase(testOutputHelper)
{
- private const string InitializeRequest = """
- {"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0.0"}}}
- """;
+ ///
+ /// Gets the test event stream store, cast to the concrete type for test-specific assertions.
+ ///
+ private TestSseEventStreamStore TestEventStreamStore => (TestSseEventStreamStore)EventStreamStore!;
+
+ ///
+ protected override ValueTask CreateEventStreamStoreAsync()
+ => new(new TestSseEventStreamStore());
[Fact]
- public async Task Server_StoresEvents_WhenEventStoreConfigured()
+ public override async Task Server_StoresEvents_WhenEventStoreConfigured()
{
- // Arrange
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore);
- await using var client = await ConnectClientAsync();
+ await base.Server_StoresEvents_WhenEventStoreConfigured();
- // Act - Make a tool call which generates events
- var result = await client.CallToolAsync("echo",
- new Dictionary { ["message"] = "test" },
- cancellationToken: TestContext.Current.CancellationToken);
+ // Additional assertion: verify events were actually stored
+ Assert.True(TestEventStreamStore.StoreEventCallCount > 0, "Expected events to be stored when EventStore is configured");
+ }
- // Assert - Events were stored
- Assert.NotNull(result);
- Assert.True(eventStreamStore.StoreEventCallCount > 0, "Expected events to be stored when EventStore is configured");
+ [Fact]
+ public override async Task Client_CanMakeMultipleRequests_WithResumabilityEnabled()
+ {
+ await base.Client_CanMakeMultipleRequests_WithResumabilityEnabled();
+
+ // Additional assertion: verify events were stored for each request
+ Assert.True(TestEventStreamStore.StoreEventCallCount >= 5, "Expected events to be stored for each request");
}
[Fact]
public async Task Server_StoresMultipleEvents_ForMultipleToolCalls()
{
// Arrange
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore);
+ await using var app = await CreateServerAsync();
await using var client = await ConnectClientAsync();
// Act - Make multiple tool calls
- var initialCount = eventStreamStore.StoreEventCallCount;
+ var initialCount = TestEventStreamStore.StoreEventCallCount;
await client.CallToolAsync("echo",
new Dictionary { ["message"] = "test1" },
cancellationToken: TestContext.Current.CancellationToken);
- var countAfterFirst = eventStreamStore.StoreEventCallCount;
+ var countAfterFirst = TestEventStreamStore.StoreEventCallCount;
await client.CallToolAsync("echo",
new Dictionary { ["message"] = "test2" },
cancellationToken: TestContext.Current.CancellationToken);
- var countAfterSecond = eventStreamStore.StoreEventCallCount;
+ var countAfterSecond = TestEventStreamStore.StoreEventCallCount;
// Assert - More events were stored for each call
Assert.True(countAfterFirst > initialCount, "Expected more events after first call");
@@ -72,61 +75,20 @@ public class ResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) :
}
[Fact]
- public async Task Client_CanMakeMultipleRequests_WithResumabilityEnabled()
+ public override async Task EnablePollingAsync_SendsSseItemWithRetryField()
{
- // Arrange
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore);
- await using var client = await ConnectClientAsync();
+ await base.EnablePollingAsync_SendsSseItemWithRetryField();
- // Act - Make many requests to verify stability
- for (int i = 0; i < 5; i++)
- {
- var result = await client.CallToolAsync("echo",
- new Dictionary { ["message"] = $"test{i}" },
- cancellationToken: TestContext.Current.CancellationToken);
-
- var textContent = Assert.Single(result.Content.OfType());
- Assert.Equal($"Echo: test{i}", textContent.Text);
- }
-
- // Assert - All requests succeeded and events were stored
- Assert.True(eventStreamStore.StoreEventCallCount >= 5, "Expected events to be stored for each request");
- }
-
- [Fact]
- public async Task Ping_WorksWithResumabilityEnabled()
- {
- // Arrange
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore);
- await using var client = await ConnectClientAsync();
-
- // Act & Assert - Ping should work
- await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
- }
-
- [Fact]
- public async Task ListTools_WorksWithResumabilityEnabled()
- {
- // Arrange
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore);
- await using var client = await ConnectClientAsync();
-
- // Act
- var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
-
- // Assert
- Assert.NotNull(tools);
- Assert.Single(tools);
+ // Additional assertion: verify that the event store received the retry interval
+ var expectedRetryInterval = TimeSpan.FromSeconds(5);
+ Assert.Contains(expectedRetryInterval, TestEventStreamStore.StoredReconnectionIntervals);
}
[Fact]
public async Task Server_WithoutEventStore_DoesNotIncludeEventId()
{
- // Arrange - Server without event store
- await using var app = await CreateServerAsync();
+ // Arrange - Server without event store (pass null explicitly)
+ await using var app = await CreateServerAsync(eventStreamStore: null);
// Act
var sseResponse = await SendInitializeAndReadSseResponseAsync(InitializeRequest);
@@ -139,8 +101,7 @@ public class ResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) :
public async Task Server_DoesNotSendPrimingEvents_ToOlderProtocolVersionClients()
{
// Arrange - Server with resumability enabled
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore);
+ await using var app = await CreateServerAsync();
// Use an older protocol version that doesn't support resumability
const string OldProtocolInitRequest = """
@@ -153,358 +114,7 @@ public class ResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) :
Assert.True(sseResponse.LastEventId is null, "Old protocol clients should not receive event IDs");
// Event store should not have been called for old clients
- Assert.Equal(0, eventStreamStore.StoreEventCallCount);
- }
-
- [Fact]
- public async Task Client_CanPollResponse_FromServer()
- {
- const string ProgressToolName = "progress_tool";
- var clientReceivedInitialValueTcs = new TaskCompletionSource();
- var clientReceivedPolledValueTcs = new TaskCompletionSource();
- var progressTool = McpServerTool.Create(async (RequestContext context, IProgress progress) =>
- {
- progress.Report(new() { Progress = 0, Message = "Initial value" });
-
- await clientReceivedInitialValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
-
- await context.EnablePollingAsync(retryInterval: TimeSpan.FromSeconds(1));
-
- progress.Report(new() { Progress = 50, Message = "Polled value" });
-
- await clientReceivedPolledValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken); ;
-
- return "Complete";
- }, options: new() { Name = ProgressToolName });
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore, configureServer: builder =>
- {
- builder.WithTools([progressTool]);
- });
- await using var client = await ConnectClientAsync();
-
- var progressHandler = new Progress(value =>
- {
- switch (value.Message)
- {
- case "Initial value":
- Assert.True(clientReceivedInitialValueTcs.TrySetResult(), "Received the initial value more than once.");
- break;
- case "Polled value":
- Assert.True(clientReceivedPolledValueTcs.TrySetResult(), "Received the polled value more than once.");
- break;
- default:
- throw new UnreachableException($"Unknown progress message '{value.Message}'");
- }
- });
-
- var result = await client.CallToolAsync(ProgressToolName, progress: progressHandler, cancellationToken: TestContext.Current.CancellationToken);
-
- Assert.False(result.IsError is true);
- Assert.Equal("Complete", result.Content.OfType().Single().Text);
- }
-
- [Fact]
- public async Task Client_CanResumePostResponseStream_AfterDisconnection()
- {
- using var faultingStreamHandler = new FaultingStreamHandler()
- {
- InnerHandler = SocketsHttpHandler,
- };
-
- HttpClient = new(faultingStreamHandler);
- ConfigureHttpClient(HttpClient);
-
- const string ProgressToolName = "progress_tool";
- const string InitialMessage = "Initial notification";
- const string ReplayedMessage = "Replayed notification";
- const string ResultMessage = "Complete";
-
- var clientReceivedInitialValueTcs = new TaskCompletionSource();
- var clientReceivedReconnectValueTcs = new TaskCompletionSource();
- var progressTool = McpServerTool.Create(async (RequestContext context, IProgress progress, CancellationToken cancellationToken) =>
- {
- progress.Report(new() { Progress = 0, Message = InitialMessage });
-
- // Make sure the client receives one message before we disconnect.
- await clientReceivedInitialValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
-
- // Simulate a network disconnection by faulting the response stream.
- var reconnectAttempt = await faultingStreamHandler.TriggerFaultAsync(TestContext.Current.CancellationToken);
-
- // Send another message that the client should receive after reconnecting.
- progress.Report(new() { Progress = 50, Message = ReplayedMessage });
-
- reconnectAttempt.Continue();
-
- // Wait for the client to receive the message via replay.
- await clientReceivedReconnectValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
-
- // Return the final result with the client still connected.
- return ResultMessage;
- }, options: new() { Name = ProgressToolName });
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore, configureServer: builder =>
- {
- builder.WithTools([progressTool]);
- });
- await using var client = await ConnectClientAsync();
-
- var initialNotificationReceivedCount = 0;
- var replayedNotificationReceivedCount = 0;
- var progressHandler = new Progress(value =>
- {
- switch (value.Message)
- {
- case InitialMessage:
- initialNotificationReceivedCount++;
- clientReceivedInitialValueTcs.TrySetResult();
- break;
- case ReplayedMessage:
- replayedNotificationReceivedCount++;
- clientReceivedReconnectValueTcs.TrySetResult();
- break;
- default:
- throw new UnreachableException($"Unknown progress message '{value.Message}'");
- }
- });
-
- var result = await client.CallToolAsync(ProgressToolName, progress: progressHandler, cancellationToken: TestContext.Current.CancellationToken);
-
- Assert.False(result.IsError is true);
- Assert.Equal(1, initialNotificationReceivedCount);
- Assert.Equal(1, replayedNotificationReceivedCount);
- Assert.Equal(ResultMessage, result.Content.OfType().Single().Text);
- }
-
- [Fact]
- public async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconnection()
- {
- var timeout = TestConstants.DefaultTimeout;
- using var faultingStreamHandler = new FaultingStreamHandler()
- {
- InnerHandler = SocketsHttpHandler,
- };
-
- HttpClient = new(faultingStreamHandler);
- ConfigureHttpClient(HttpClient);
-
- var eventStreamStore = new TestSseEventStreamStore();
-
- // Capture the server instance via RunSessionHandler
- var serverTcs = new TaskCompletionSource();
-
- await using var app = await CreateServerAsync(eventStreamStore, configureTransport: options =>
- {
- options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
- {
- serverTcs.TrySetResult(mcpServer);
- return mcpServer.RunAsync(cancellationToken);
- };
- });
-
- await using var client = await ConnectClientAsync();
-
- // Get the server instance
- var server = await serverTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
-
- // Set up notification tracking with unique messages
- var clientReceivedInitialNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var clientReceivedReplayedNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var clientReceivedReconnectNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-
- const string CustomNotificationMethod = "test/custom_notification";
- const string InitialMessage = "Initial notification";
- const string ReplayedMessage = "Replayed notification";
- const string ReconnectMessage = "Reconnect notification";
-
- var initialNotificationReceivedCount = 0;
- var replayedNotificationReceivedCount = 0;
- var reconnectNotificationReceivedCount = 0;
-
- await using var _ = client.RegisterNotificationHandler(CustomNotificationMethod, (notification, cancellationToken) =>
- {
- var message = notification.Params?["message"]?.GetValue();
- switch (message)
- {
- case InitialMessage:
- initialNotificationReceivedCount++;
- clientReceivedInitialNotificationTcs.TrySetResult();
- break;
- case ReplayedMessage:
- replayedNotificationReceivedCount++;
- clientReceivedReplayedNotificationTcs.TrySetResult();
- break;
- case ReconnectMessage:
- reconnectNotificationReceivedCount++;
- clientReceivedReconnectNotificationTcs.TrySetResult();
- break;
- default:
- throw new UnreachableException($"Unknown notification message '{message}'");
- }
- return default;
- });
-
- // Wait for the client's unsolicited message stream to be established before sending notifications
- await faultingStreamHandler.WaitForUnsolicitedMessageStreamAsync(TestContext.Current.CancellationToken);
-
- // Send a custom notification to the client on the unsolicited message stream
- await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = InitialMessage }, cancellationToken: TestContext.Current.CancellationToken);
-
- // Wait for client to receive the first notification
- await clientReceivedInitialNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
-
- // Fault the unsolicited message stream (GET SSE)
- var reconnectAttempt = await faultingStreamHandler.TriggerFaultAsync(TestContext.Current.CancellationToken);
-
- // Send another notification while the client is disconnected - this should be stored
- await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = ReplayedMessage }, cancellationToken: TestContext.Current.CancellationToken);
-
- // Allow the client to reconnect
- reconnectAttempt.Continue();
-
- // Wait for client to receive the notification via replay
- await clientReceivedReplayedNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
-
- // Send a final notification while the client has reconnected - this should be handled by the transport
- await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = ReconnectMessage }, cancellationToken: TestContext.Current.CancellationToken);
-
- // Wait for the client to receive the final notification
- await clientReceivedReconnectNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
-
- // Assert each notification was received exactly once
- Assert.Equal(1, initialNotificationReceivedCount);
- Assert.Equal(1, replayedNotificationReceivedCount);
- Assert.Equal(1, reconnectNotificationReceivedCount);
- }
-
- [Fact]
- public async Task Server_Returns400_WhenLastEventIdRefersToWrongSession()
- {
- // Arrange - Create server with event store
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore);
-
- // First, initialize a session and make a call to generate some events
- using var initRequest = new HttpRequestMessage(HttpMethod.Post, "/")
- {
- Headers =
- {
- Accept = { new("application/json"), new("text/event-stream") }
- },
- Content = new StringContent(InitializeRequest, Encoding.UTF8, "application/json"),
- };
- var initResponse = await HttpClient.SendAsync(initRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
- initResponse.EnsureSuccessStatusCode();
-
- // Get the session ID from the response
- var sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First();
-
- // Read the SSE response to get an event ID
- await using var initStream = await initResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
- string? eventId = null;
- await foreach (var sseItem in SseParser.Create(initStream).EnumerateAsync(TestContext.Current.CancellationToken))
- {
- if (!string.IsNullOrEmpty(sseItem.EventId))
- {
- eventId = sseItem.EventId;
- }
- }
-
- Assert.NotNull(eventId);
-
- // Act - Try to resume with a different session ID but the same event ID
- var wrongSessionId = "wrong-session-id";
- using var resumeRequest = new HttpRequestMessage(HttpMethod.Get, "/")
- {
- Headers =
- {
- Accept = { new("text/event-stream") },
- }
- };
- resumeRequest.Headers.Add("Mcp-Session-Id", wrongSessionId);
- resumeRequest.Headers.Add("Last-Event-ID", eventId);
-
- var resumeResponse = await HttpClient.SendAsync(resumeRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
-
- // Assert - First we get 404 because the wrong session doesn't exist
- Assert.Equal(HttpStatusCode.NotFound, resumeResponse.StatusCode);
-
- // Now test with an existing session but event ID from a different session
- // Create a second session
- using var initRequest2 = new HttpRequestMessage(HttpMethod.Post, "/")
- {
- Headers =
- {
- Accept = { new("application/json"), new("text/event-stream") }
- },
- Content = new StringContent(InitializeRequest, Encoding.UTF8, "application/json"),
- };
- var initResponse2 = await HttpClient.SendAsync(initRequest2, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
- initResponse2.EnsureSuccessStatusCode();
-
- var sessionId2 = initResponse2.Headers.GetValues("Mcp-Session-Id").First();
- Assert.NotEqual(sessionId, sessionId2);
-
- // Read the second session's response
- await using var initStream2 = await initResponse2.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
- await foreach (var _ in SseParser.Create(initStream2).EnumerateAsync(TestContext.Current.CancellationToken))
- {
- // Consume the stream
- }
-
- // Try to use session 2's ID but with an event ID from session 1
- using var mismatchRequest = new HttpRequestMessage(HttpMethod.Get, "/")
- {
- Headers =
- {
- Accept = { new("text/event-stream") },
- }
- };
- mismatchRequest.Headers.Add("Mcp-Session-Id", sessionId2);
- mismatchRequest.Headers.Add("Last-Event-ID", eventId); // This event ID belongs to session 1
-
- var mismatchResponse = await HttpClient.SendAsync(mismatchRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
-
- // Assert - Should get 400 Bad Request because the event ID doesn't match the session
- Assert.Equal(HttpStatusCode.BadRequest, mismatchResponse.StatusCode);
-
- // Verify the error message
- var responseBody = await mismatchResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
- var errorResponse = JsonNode.Parse(responseBody);
- Assert.NotNull(errorResponse);
- var errorMessage = errorResponse["error"]?["message"]?.GetValue();
- Assert.Equal("Bad Request: The Last-Event-ID header refers to a session with a different session ID.", errorMessage);
- }
-
- [Fact]
- public async Task EnablePollingAsync_SendsSseItemWithRetryField()
- {
- // Arrange
- const string PollingToolName = "polling_tool";
- var expectedRetryInterval = TimeSpan.FromSeconds(5);
- var pollingTool = McpServerTool.Create(async (RequestContext context) =>
- {
- await context.EnablePollingAsync(retryInterval: expectedRetryInterval);
- return "Polling enabled";
- }, options: new() { Name = PollingToolName });
-
- var eventStreamStore = new TestSseEventStreamStore();
- await using var app = await CreateServerAsync(eventStreamStore, configureServer: builder =>
- {
- builder.WithTools([pollingTool]);
- });
- await using var client = await ConnectClientAsync();
-
- // Act - Call the tool that enables polling
- var result = await client.CallToolAsync(PollingToolName, cancellationToken: TestContext.Current.CancellationToken);
-
- // Assert - The result should be successful
- Assert.False(result.IsError is true);
- Assert.Equal("Polling enabled", result.Content.OfType().Single().Text);
-
- // Verify that the event store received the retry interval
- Assert.Contains(expectedRetryInterval, eventStreamStore.StoredReconnectionIntervals);
+ Assert.Equal(0, TestEventStreamStore.StoreEventCallCount);
}
[Fact]
@@ -542,82 +152,6 @@ public class ResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) :
await blockingStore.DisposedTask.WaitAsync(timeoutCts.Token);
}
- [McpServerToolType]
- private class ResumabilityTestTools
- {
- [McpServerTool(Name = "echo"), Description("Echoes the message back")]
- public static string Echo(string message) => $"Echo: {message}";
- }
-
- private async Task CreateServerAsync(
- ISseEventStreamStore? eventStreamStore = null,
- Action? configureServer = null,
- Action? configureTransport = null)
- {
- var serverBuilder = Builder.Services.AddMcpServer()
- .WithHttpTransport(options =>
- {
- options.EventStreamStore = eventStreamStore;
- configureTransport?.Invoke(options);
- })
- .WithTools();
-
- configureServer?.Invoke(serverBuilder);
-
- var app = Builder.Build();
- app.MapMcp();
-
- await app.StartAsync(TestContext.Current.CancellationToken);
- return app;
- }
-
- private async Task ConnectClientAsync()
- {
- var transport = new HttpClientTransport(new HttpClientTransportOptions
- {
- Endpoint = new Uri("http://localhost:5000/"),
- TransportMode = HttpTransportMode.StreamableHttp,
- }, HttpClient, LoggerFactory);
-
- return await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
- cancellationToken: TestContext.Current.CancellationToken);
- }
-
- private async Task SendInitializeAndReadSseResponseAsync(string initializeRequest)
- {
- using var requestContent = new StringContent(initializeRequest, Encoding.UTF8, "application/json");
- using var request = new HttpRequestMessage(HttpMethod.Post, "/")
- {
- Headers =
- {
- Accept = { new("application/json"), new("text/event-stream") }
- },
- Content = requestContent,
- };
-
- var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
- TestContext.Current.CancellationToken);
-
- response.EnsureSuccessStatusCode();
-
- var sseResponse = new SseResponse();
- await using var stream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
- await foreach (var sseItem in SseParser.Create(stream).EnumerateAsync(TestContext.Current.CancellationToken))
- {
- if (!string.IsNullOrEmpty(sseItem.EventId))
- {
- sseResponse.LastEventId = sseItem.EventId;
- }
- }
-
- return sseResponse;
- }
-
- private struct SseResponse
- {
- public string? LastEventId { get; set; }
- }
-
///
/// A test event stream store that blocks on WriteEventAsync for response messages,
/// allowing the test to cancel the operation and verify proper cleanup.
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs
new file mode 100644
index 0000000..703697f
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs
@@ -0,0 +1,560 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Net;
+using System.Net.ServerSentEvents;
+using System.Text;
+using System.Text.Json.Nodes;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.AspNetCore.Tests.Utils;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
+
+namespace ModelContextProtocol.AspNetCore.Tests;
+
+///
+/// Base class for SSE resumability integration tests that can be run against different
+/// implementations.
+///
+///
+///
+/// Tests in this class verify resumability behavior without relying on implementation-specific
+/// internals of the event store. Derived classes can override virtual tests to add additional
+/// assertions specific to their event store implementation.
+///
+///
+/// The method must be implemented by derived classes
+/// to provide the specific event store implementation to test.
+///
+///
+public abstract class ResumabilityIntegrationTestsBase(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
+{
+ ///
+ /// The initialize request JSON for the current protocol version.
+ ///
+ protected const string InitializeRequest = """
+ {"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0.0"}}}
+ """;
+
+ ///
+ /// Gets the event stream store created for the current test.
+ ///
+ ///
+ /// This is set after is called.
+ ///
+ protected ISseEventStreamStore? EventStreamStore { get; private set; }
+
+ ///
+ /// Creates the event stream store implementation to use for this test.
+ ///
+ /// The event stream store instance.
+ protected abstract ValueTask CreateEventStreamStoreAsync();
+
+ [Fact]
+ public virtual async Task Server_StoresEvents_WhenEventStoreConfigured()
+ {
+ // Arrange
+ await using var app = await CreateServerAsync();
+ await using var client = await ConnectClientAsync();
+
+ // Act - Make a tool call which generates events
+ var result = await client.CallToolAsync("echo",
+ new Dictionary { ["message"] = "test" },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert - The call succeeded
+ Assert.NotNull(result);
+ var textContent = Assert.Single(result.Content.OfType());
+ Assert.Equal("Echo: test", textContent.Text);
+ }
+
+ [Fact]
+ public virtual async Task Client_CanMakeMultipleRequests_WithResumabilityEnabled()
+ {
+ // Arrange
+ await using var app = await CreateServerAsync();
+ await using var client = await ConnectClientAsync();
+
+ // Act - Make many requests to verify stability
+ for (int i = 0; i < 5; i++)
+ {
+ var result = await client.CallToolAsync("echo",
+ new Dictionary { ["message"] = $"test{i}" },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var textContent = Assert.Single(result.Content.OfType());
+ Assert.Equal($"Echo: test{i}", textContent.Text);
+ }
+ }
+
+ [Fact]
+ public virtual async Task Ping_WorksWithResumabilityEnabled()
+ {
+ // Arrange
+ await using var app = await CreateServerAsync();
+ await using var client = await ConnectClientAsync();
+
+ // Act & Assert - Ping should work
+ await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public virtual async Task ListTools_WorksWithResumabilityEnabled()
+ {
+ // Arrange
+ await using var app = await CreateServerAsync();
+ await using var client = await ConnectClientAsync();
+
+ // Act
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert
+ Assert.NotNull(tools);
+ Assert.Single(tools);
+ }
+
+ [Fact]
+ public virtual async Task Client_CanPollResponse_FromServer()
+ {
+ const string ProgressToolName = "progress_tool";
+ var clientReceivedInitialValueTcs = new TaskCompletionSource();
+ var clientReceivedPolledValueTcs = new TaskCompletionSource();
+ var progressTool = McpServerTool.Create(async (RequestContext context, IProgress progress) =>
+ {
+ progress.Report(new() { Progress = 0, Message = "Initial value" });
+
+ await clientReceivedInitialValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
+
+ await context.EnablePollingAsync(retryInterval: TimeSpan.FromSeconds(1));
+
+ progress.Report(new() { Progress = 50, Message = "Polled value" });
+
+ await clientReceivedPolledValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
+
+ return "Complete";
+ }, options: new() { Name = ProgressToolName });
+
+ await using var app = await CreateServerAsync(configureServer: builder =>
+ {
+ builder.WithTools([progressTool]);
+ });
+ await using var client = await ConnectClientAsync();
+
+ var progressHandler = new Progress(value =>
+ {
+ switch (value.Message)
+ {
+ case "Initial value":
+ Assert.True(clientReceivedInitialValueTcs.TrySetResult(), "Received the initial value more than once.");
+ break;
+ case "Polled value":
+ Assert.True(clientReceivedPolledValueTcs.TrySetResult(), "Received the polled value more than once.");
+ break;
+ default:
+ throw new UnreachableException($"Unknown progress message '{value.Message}'");
+ }
+ });
+
+ var result = await client.CallToolAsync(ProgressToolName, progress: progressHandler, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsError is true);
+ Assert.Equal("Complete", result.Content.OfType().Single().Text);
+ }
+
+ [Fact]
+ public virtual async Task Client_CanResumePostResponseStream_AfterDisconnection()
+ {
+ using var faultingStreamHandler = new FaultingStreamHandler()
+ {
+ InnerHandler = SocketsHttpHandler,
+ };
+
+ HttpClient = new(faultingStreamHandler);
+ ConfigureHttpClient(HttpClient);
+
+ const string ProgressToolName = "progress_tool";
+ const string InitialMessage = "Initial notification";
+ const string ReplayedMessage = "Replayed notification";
+ const string ResultMessage = "Complete";
+
+ var clientReceivedInitialValueTcs = new TaskCompletionSource();
+ var clientReceivedReconnectValueTcs = new TaskCompletionSource();
+ var progressTool = McpServerTool.Create(async (RequestContext context, IProgress progress, CancellationToken cancellationToken) =>
+ {
+ progress.Report(new() { Progress = 0, Message = InitialMessage });
+
+ // Make sure the client receives one message before we disconnect.
+ await clientReceivedInitialValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
+
+ // Simulate a network disconnection by faulting the response stream.
+ var reconnectAttempt = await faultingStreamHandler.TriggerFaultAsync(TestContext.Current.CancellationToken);
+
+ // Send another message that the client should receive after reconnecting.
+ progress.Report(new() { Progress = 50, Message = ReplayedMessage });
+
+ reconnectAttempt.Continue();
+
+ // Wait for the client to receive the message via replay.
+ await clientReceivedReconnectValueTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
+
+ // Return the final result with the client still connected.
+ return ResultMessage;
+ }, options: new() { Name = ProgressToolName });
+
+ await using var app = await CreateServerAsync(configureServer: builder =>
+ {
+ builder.WithTools([progressTool]);
+ });
+ await using var client = await ConnectClientAsync();
+
+ var initialNotificationReceivedCount = 0;
+ var replayedNotificationReceivedCount = 0;
+ var progressHandler = new Progress(value =>
+ {
+ switch (value.Message)
+ {
+ case InitialMessage:
+ initialNotificationReceivedCount++;
+ clientReceivedInitialValueTcs.TrySetResult();
+ break;
+ case ReplayedMessage:
+ replayedNotificationReceivedCount++;
+ clientReceivedReconnectValueTcs.TrySetResult();
+ break;
+ default:
+ throw new UnreachableException($"Unknown progress message '{value.Message}'");
+ }
+ });
+
+ var result = await client.CallToolAsync(ProgressToolName, progress: progressHandler, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsError is true);
+ Assert.Equal(1, initialNotificationReceivedCount);
+ Assert.Equal(1, replayedNotificationReceivedCount);
+ Assert.Equal(ResultMessage, result.Content.OfType().Single().Text);
+ }
+
+ [Fact]
+ public virtual async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconnection()
+ {
+ var timeout = TestConstants.DefaultTimeout;
+ using var faultingStreamHandler = new FaultingStreamHandler()
+ {
+ InnerHandler = SocketsHttpHandler,
+ };
+
+ HttpClient = new(faultingStreamHandler);
+ ConfigureHttpClient(HttpClient);
+
+ // Capture the server instance via RunSessionHandler
+ var serverTcs = new TaskCompletionSource();
+
+ await using var app = await CreateServerAsync(configureTransport: options =>
+ {
+ options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
+ {
+ serverTcs.TrySetResult(mcpServer);
+ return mcpServer.RunAsync(cancellationToken);
+ };
+ });
+
+ await using var client = await ConnectClientAsync();
+
+ // Get the server instance
+ var server = await serverTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
+
+ // Set up notification tracking with unique messages
+ var clientReceivedInitialNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var clientReceivedReplayedNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var clientReceivedReconnectNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ const string CustomNotificationMethod = "test/custom_notification";
+ const string InitialMessage = "Initial notification";
+ const string ReplayedMessage = "Replayed notification";
+ const string ReconnectMessage = "Reconnect notification";
+
+ var initialNotificationReceivedCount = 0;
+ var replayedNotificationReceivedCount = 0;
+ var reconnectNotificationReceivedCount = 0;
+
+ await using var _ = client.RegisterNotificationHandler(CustomNotificationMethod, (notification, cancellationToken) =>
+ {
+ var message = notification.Params?["message"]?.GetValue();
+ switch (message)
+ {
+ case InitialMessage:
+ initialNotificationReceivedCount++;
+ clientReceivedInitialNotificationTcs.TrySetResult();
+ break;
+ case ReplayedMessage:
+ replayedNotificationReceivedCount++;
+ clientReceivedReplayedNotificationTcs.TrySetResult();
+ break;
+ case ReconnectMessage:
+ reconnectNotificationReceivedCount++;
+ clientReceivedReconnectNotificationTcs.TrySetResult();
+ break;
+ default:
+ throw new UnreachableException($"Unknown notification message '{message}'");
+ }
+ return default;
+ });
+
+ // Wait for the client's unsolicited message stream to be established before sending notifications
+ await faultingStreamHandler.WaitForUnsolicitedMessageStreamAsync(TestContext.Current.CancellationToken);
+
+ // Send a custom notification to the client on the unsolicited message stream
+ await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = InitialMessage }, cancellationToken: TestContext.Current.CancellationToken);
+
+ // Wait for client to receive the first notification
+ await clientReceivedInitialNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
+
+ // Fault the unsolicited message stream (GET SSE)
+ var reconnectAttempt = await faultingStreamHandler.TriggerFaultAsync(TestContext.Current.CancellationToken);
+
+ // Send another notification while the client is disconnected - this should be stored
+ await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = ReplayedMessage }, cancellationToken: TestContext.Current.CancellationToken);
+
+ // Allow the client to reconnect
+ reconnectAttempt.Continue();
+
+ // Wait for client to receive the notification via replay
+ await clientReceivedReplayedNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
+
+ // Send a final notification while the client has reconnected - this should be handled by the transport
+ await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = ReconnectMessage }, cancellationToken: TestContext.Current.CancellationToken);
+
+ // Wait for the client to receive the final notification
+ await clientReceivedReconnectNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
+
+ // Assert each notification was received exactly once
+ Assert.Equal(1, initialNotificationReceivedCount);
+ Assert.Equal(1, replayedNotificationReceivedCount);
+ Assert.Equal(1, reconnectNotificationReceivedCount);
+ }
+
+ [Fact]
+ public virtual async Task Server_Returns400_WhenLastEventIdRefersToWrongSession()
+ {
+ // Arrange - Create server with event store
+ await using var app = await CreateServerAsync();
+
+ // First, initialize a session and make a call to generate some events
+ using var initRequest = new HttpRequestMessage(HttpMethod.Post, "/")
+ {
+ Headers =
+ {
+ Accept = { new("application/json"), new("text/event-stream") }
+ },
+ Content = new StringContent(InitializeRequest, Encoding.UTF8, "application/json"),
+ };
+ var initResponse = await HttpClient.SendAsync(initRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
+ initResponse.EnsureSuccessStatusCode();
+
+ // Get the session ID from the response
+ var sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First();
+
+ // Read the SSE response to get an event ID
+ await using var initStream = await initResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
+ string? eventId = null;
+ await foreach (var sseItem in SseParser.Create(initStream).EnumerateAsync(TestContext.Current.CancellationToken))
+ {
+ if (!string.IsNullOrEmpty(sseItem.EventId))
+ {
+ eventId = sseItem.EventId;
+ }
+ }
+
+ Assert.NotNull(eventId);
+
+ // Act - Try to resume with a different session ID but the same event ID
+ var wrongSessionId = "wrong-session-id";
+ using var resumeRequest = new HttpRequestMessage(HttpMethod.Get, "/")
+ {
+ Headers =
+ {
+ Accept = { new("text/event-stream") },
+ }
+ };
+ resumeRequest.Headers.Add("Mcp-Session-Id", wrongSessionId);
+ resumeRequest.Headers.Add("Last-Event-ID", eventId);
+
+ var resumeResponse = await HttpClient.SendAsync(resumeRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
+
+ // Assert - First we get 404 because the wrong session doesn't exist
+ Assert.Equal(HttpStatusCode.NotFound, resumeResponse.StatusCode);
+
+ // Now test with an existing session but event ID from a different session
+ // Create a second session
+ using var initRequest2 = new HttpRequestMessage(HttpMethod.Post, "/")
+ {
+ Headers =
+ {
+ Accept = { new("application/json"), new("text/event-stream") }
+ },
+ Content = new StringContent(InitializeRequest, Encoding.UTF8, "application/json"),
+ };
+ var initResponse2 = await HttpClient.SendAsync(initRequest2, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
+ initResponse2.EnsureSuccessStatusCode();
+
+ var sessionId2 = initResponse2.Headers.GetValues("Mcp-Session-Id").First();
+ Assert.NotEqual(sessionId, sessionId2);
+
+ // Read the second session's response
+ await using var initStream2 = await initResponse2.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
+ await foreach (var _ in SseParser.Create(initStream2).EnumerateAsync(TestContext.Current.CancellationToken))
+ {
+ // Consume the stream
+ }
+
+ // Try to use session 2's ID but with an event ID from session 1
+ using var mismatchRequest = new HttpRequestMessage(HttpMethod.Get, "/")
+ {
+ Headers =
+ {
+ Accept = { new("text/event-stream") },
+ }
+ };
+ mismatchRequest.Headers.Add("Mcp-Session-Id", sessionId2);
+ mismatchRequest.Headers.Add("Last-Event-ID", eventId); // This event ID belongs to session 1
+
+ var mismatchResponse = await HttpClient.SendAsync(mismatchRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
+
+ // Assert - Should get 400 Bad Request because the event ID doesn't match the session
+ Assert.Equal(HttpStatusCode.BadRequest, mismatchResponse.StatusCode);
+
+ // Verify the error message
+ var responseBody = await mismatchResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ var errorResponse = JsonNode.Parse(responseBody);
+ Assert.NotNull(errorResponse);
+ var errorMessage = errorResponse["error"]?["message"]?.GetValue();
+ Assert.Equal("Bad Request: The Last-Event-ID header refers to a session with a different session ID.", errorMessage);
+ }
+
+ [Fact]
+ public virtual async Task EnablePollingAsync_SendsSseItemWithRetryField()
+ {
+ // Arrange
+ const string PollingToolName = "polling_tool";
+ var expectedRetryInterval = TimeSpan.FromSeconds(5);
+ var pollingTool = McpServerTool.Create(async (RequestContext context) =>
+ {
+ await context.EnablePollingAsync(retryInterval: expectedRetryInterval);
+ return "Polling enabled";
+ }, options: new() { Name = PollingToolName });
+
+ await using var app = await CreateServerAsync(configureServer: builder =>
+ {
+ builder.WithTools([pollingTool]);
+ });
+ await using var client = await ConnectClientAsync();
+
+ // Act - Call the tool that enables polling
+ var result = await client.CallToolAsync(PollingToolName, cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert - The result should be successful
+ Assert.False(result.IsError is true);
+ Assert.Equal("Polling enabled", result.Content.OfType().Single().Text);
+ }
+
+ [McpServerToolType]
+ protected class ResumabilityTestTools
+ {
+ [McpServerTool(Name = "echo"), Description("Echoes the message back")]
+ public static string Echo(string message) => $"Echo: {message}";
+ }
+
+ ///
+ /// Creates a server with the event stream store from .
+ ///
+ protected async Task CreateServerAsync(
+ Action? configureServer = null,
+ Action? configureTransport = null)
+ {
+ EventStreamStore = await CreateEventStreamStoreAsync();
+ return await CreateServerAsync(EventStreamStore, configureServer, configureTransport);
+ }
+
+ ///
+ /// Creates a server with the specified event stream store.
+ ///
+ protected async Task CreateServerAsync(
+ ISseEventStreamStore? eventStreamStore,
+ Action? configureServer = null,
+ Action? configureTransport = null)
+ {
+ var serverBuilder = Builder.Services.AddMcpServer()
+ .WithHttpTransport(options =>
+ {
+ options.EventStreamStore = eventStreamStore;
+ configureTransport?.Invoke(options);
+ })
+ .WithTools();
+
+ configureServer?.Invoke(serverBuilder);
+
+ var app = Builder.Build();
+ app.MapMcp();
+
+ await app.StartAsync(TestContext.Current.CancellationToken);
+ return app;
+ }
+
+ ///
+ /// Connects a client to the server.
+ ///
+ protected async Task ConnectClientAsync()
+ {
+ var transport = new HttpClientTransport(new HttpClientTransportOptions
+ {
+ Endpoint = new Uri("http://localhost:5000/"),
+ TransportMode = HttpTransportMode.StreamableHttp,
+ }, HttpClient, LoggerFactory);
+
+ return await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// Sends an initialize request and reads the SSE response.
+ ///
+ protected async Task SendInitializeAndReadSseResponseAsync(string initializeRequest)
+ {
+ using var requestContent = new StringContent(initializeRequest, Encoding.UTF8, "application/json");
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/")
+ {
+ Headers =
+ {
+ Accept = { new("application/json"), new("text/event-stream") }
+ },
+ Content = requestContent,
+ };
+
+ var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
+ TestContext.Current.CancellationToken);
+
+ response.EnsureSuccessStatusCode();
+
+ var sseResponse = new SseResponse();
+ await using var stream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
+ await foreach (var sseItem in SseParser.Create(stream).EnumerateAsync(TestContext.Current.CancellationToken))
+ {
+ if (!string.IsNullOrEmpty(sseItem.EventId))
+ {
+ sseResponse.LastEventId = sseItem.EventId;
+ }
+ }
+
+ return sseResponse;
+ }
+
+ ///
+ /// Response data from an SSE stream.
+ ///
+ protected struct SseResponse
+ {
+ public string? LastEventId { get; set; }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
index 3aba865..0985f4c 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -41,6 +41,10 @@
+
+
+
+
@@ -57,6 +61,7 @@
+
@@ -78,6 +83,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs
new file mode 100644
index 0000000..1ec3cac
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs
@@ -0,0 +1,1752 @@
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
+using System.Net.ServerSentEvents;
+
+namespace ModelContextProtocol.Tests;
+
+///
+/// Tests for .
+///
+public class DistributedCacheEventStreamStoreTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
+{
+ private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
+
+ private static IDistributedCache CreateMemoryCache()
+ {
+ var options = Options.Create(new MemoryDistributedCacheOptions());
+ return new MemoryDistributedCache(options);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsArgumentNullException_WhenCacheIsNull()
+ {
+ Assert.Throws("cache", () => new DistributedCacheEventStreamStore(null!));
+ }
+
+ [Fact]
+ public async Task CreateStreamAsync_ThrowsArgumentNullException_WhenOptionsIsNull()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+
+ // Act & Assert
+ await Assert.ThrowsAsync("options",
+ async () => await store.CreateStreamAsync(null!, CancellationToken));
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_AssignsUniqueEventId_WhenItemHasNoEventId()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ var item = new SseItem(null);
+
+ // Act
+ var result = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert
+ Assert.NotNull(result.EventId);
+ Assert.NotEmpty(result.EventId);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_SkipsAssigningEventId_WhenItemAlreadyHasEventId()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ var existingEventId = "existing-event-id";
+ var item = new SseItem(null) { EventId = existingEventId };
+
+ // Act
+ var result = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert
+ Assert.Equal(existingEventId, result.EventId);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_PreservesDataProperty_InReturnedItem()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ var message = new JsonRpcNotification { Method = "test/notification" };
+ var item = new SseItem(message);
+
+ // Act
+ var result = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert - Data should be preserved in the returned item (same reference)
+ Assert.Same(message, result.Data);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_PreservesEventTypeProperty_InReturnedItem()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ var item = new SseItem(null, "custom-event-type");
+
+ // Act
+ var result = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert
+ Assert.Equal("custom-event-type", result.EventType);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_PreservesReconnectionIntervalProperty_InStoredEvent()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var expectedInterval = TimeSpan.FromSeconds(5);
+ var item = new SseItem(null) { ReconnectionInterval = expectedInterval };
+
+ // Act
+ var result = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert - ReconnectionInterval should be preserved in returned item
+ Assert.Equal(expectedInterval, result.ReconnectionInterval);
+
+ // Get a reader and verify ReconnectionInterval is preserved after round-trip
+ var reader = await store.GetStreamReaderAsync(result.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Reader should not return the event we just wrote (it starts after lastEventId)
+ Assert.Empty(events);
+
+ // Write another event and verify it can be read with correct ReconnectionInterval
+ var secondItem = new SseItem(null) { ReconnectionInterval = TimeSpan.FromSeconds(10) };
+ _ = await writer.WriteEventAsync(secondItem, CancellationToken);
+
+ // Re-fetch reader using the first event ID to get the second event
+ reader = await store.GetStreamReaderAsync(result.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ Assert.Single(events);
+ Assert.Equal(TimeSpan.FromSeconds(10), events[0].ReconnectionInterval);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_HandlesNullReconnectionInterval_InStoredEvent()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write an event WITH a reconnection interval first
+ var firstItem = new SseItem(null) { ReconnectionInterval = TimeSpan.FromSeconds(5) };
+ var firstResult = await writer.WriteEventAsync(firstItem, CancellationToken);
+
+ // Write an event WITHOUT a reconnection interval
+ var secondItem = new SseItem(null);
+ var secondResult = await writer.WriteEventAsync(secondItem, CancellationToken);
+ Assert.Null(secondResult.ReconnectionInterval);
+
+ // Get a reader starting after the first event
+ var reader = await store.GetStreamReaderAsync(firstResult.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Should get the second event with null ReconnectionInterval
+ Assert.Single(events);
+ Assert.Null(events[0].ReconnectionInterval);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_HandlesNullData_AssignsEventIdAndStoresEvent()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var item = new SseItem(null);
+
+ // Act
+ var result = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert - Event ID should be assigned
+ Assert.NotNull(result.EventId);
+
+ // Assert - Event should be retrievable
+ var reader = await store.GetStreamReaderAsync(result.EventId, CancellationToken);
+ Assert.NotNull(reader);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_StoresEventWithCorrectSlidingExpiration()
+ {
+ // Arrange - Use a mock cache to verify expiration options
+ var mockCache = new TestDistributedCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ EventSlidingExpiration = TimeSpan.FromMinutes(15)
+ };
+ var store = new DistributedCacheEventStreamStore(mockCache, customOptions);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ var item = new SseItem(null);
+
+ // Act
+ await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert - Verify at least one call used the expected sliding expiration
+ Assert.Contains(mockCache.SetCalls, call =>
+ call.Key.Contains("event:") &&
+ call.Options.SlidingExpiration == TimeSpan.FromMinutes(15));
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_StoresEventWithCorrectAbsoluteExpiration()
+ {
+ // Arrange
+ var mockCache = new TestDistributedCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ EventAbsoluteExpiration = TimeSpan.FromHours(3)
+ };
+ var store = new DistributedCacheEventStreamStore(mockCache, customOptions);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ var item = new SseItem(null);
+
+ // Act
+ await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert
+ Assert.Contains(mockCache.SetCalls, call =>
+ call.Key.Contains("event:") &&
+ call.Options.AbsoluteExpirationRelativeToNow == TimeSpan.FromHours(3));
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_UpdatesStreamMetadata_AfterEachWrite()
+ {
+ // Arrange
+ var mockCache = new TestDistributedCache();
+ var store = new DistributedCacheEventStreamStore(mockCache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ var item = new SseItem(null);
+
+ // Act
+ await writer.WriteEventAsync(item, CancellationToken);
+
+ // Assert - Metadata should have been updated
+ Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("meta:"));
+ }
+
+ [Fact]
+ public async Task SetModeAsync_PersistsModeChangeToMetadata()
+ {
+ // Arrange
+ var mockCache = new TestDistributedCache();
+ var store = new DistributedCacheEventStreamStore(mockCache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ mockCache.SetCalls.Clear(); // Clear calls from CreateStreamAsync setup
+
+ // Act
+ await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken);
+
+ // Assert - Metadata should have been updated with the new mode
+ Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("meta:"));
+ }
+
+ [Fact]
+ public async Task SetModeAsync_ModeChangeReflectedInReader()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(10)
+ };
+ var store = new DistributedCacheEventStreamStore(cache, customOptions);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write an event to have something to read
+ var item = new SseItem(new JsonRpcNotification { Method = "test" });
+ var writtenItem = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Get a reader based on the event ID (starting at sequence 1, reader will wait for seq 2+)
+ var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Change mode to Polling while reader exists
+ await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken);
+
+ // Assert - Reader should complete immediately in polling mode (no new events to read)
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(cts.Token))
+ {
+ events.Add(evt);
+ }
+
+ // In polling mode, reader should complete without waiting for new events
+ Assert.Empty(events); // No events after the one we used to create the reader
+ }
+
+ [Fact]
+ public async Task DisposeAsync_MarksStreamAsCompleted()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write an event so we can get a reader
+ var item = new SseItem(null);
+ var writtenItem = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Act
+ await writer.DisposeAsync();
+
+ // Assert - Reader should see the stream as completed and exit immediately
+ var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(cts.Token))
+ {
+ events.Add(evt);
+ }
+
+ // The reader should complete without waiting for new events because stream is completed
+ Assert.Empty(events); // No new events after the one we used to create the reader
+ }
+
+ [Fact]
+ public async Task DisposeAsync_IsIdempotent()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Act - Call DisposeAsync multiple times
+ await writer.DisposeAsync();
+ await writer.DisposeAsync();
+ await writer.DisposeAsync();
+
+ // Assert - No exception thrown, operation is idempotent
+ // If we got here without exception, the test passes
+ }
+
+ [Fact]
+ public async Task DisposeAsync_UpdatesMetadata_WithIsCompletedFlag()
+ {
+ // Arrange
+ var mockCache = new TestDistributedCache();
+ var store = new DistributedCacheEventStreamStore(mockCache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ mockCache.SetCalls.Clear(); // Clear calls from CreateStreamAsync
+
+ // Act
+ await writer.DisposeAsync();
+
+ // Assert - Metadata should have been updated
+ Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("meta:"));
+ }
+
+ [Fact]
+ public async Task GetStreamReaderAsync_ThrowsArgumentNullException_WhenLastEventIdIsNull()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+
+ // Act & Assert
+ await Assert.ThrowsAsync("lastEventId",
+ async () => await store.GetStreamReaderAsync(null!, CancellationToken));
+ }
+
+ [Fact]
+ public async Task GetStreamReaderAsync_ReturnsNull_WhenEventIdIsUnparseable()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+
+ // Act - Try various invalid event ID formats
+ var result1 = await store.GetStreamReaderAsync("invalid-format", CancellationToken);
+ var result2 = await store.GetStreamReaderAsync("only:two:parts:here", CancellationToken);
+ var result3 = await store.GetStreamReaderAsync("", CancellationToken);
+
+ // Assert
+ Assert.Null(result1);
+ Assert.Null(result2);
+ Assert.Null(result3);
+ }
+
+ [Fact]
+ public async Task GetStreamReaderAsync_ReturnsNull_WhenStreamMetadataDoesNotExist()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+
+ // Create a valid-looking event ID for a stream that doesn't exist
+ var fakeEventId = DistributedCacheEventIdFormatter.Format("nonexistent-session", "nonexistent-stream", 1);
+
+ // Act
+ var reader = await store.GetStreamReaderAsync(fakeEventId, CancellationToken);
+
+ // Assert
+ Assert.Null(reader);
+ }
+
+ [Fact]
+ public async Task GetStreamReaderAsync_ReturnsReaderWithCorrectSessionIdAndStreamId()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "my-session",
+ StreamId = "my-stream",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write an event to get a valid event ID
+ var item = new SseItem(null);
+ var writtenItem = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Act
+ var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken);
+
+ // Assert
+ Assert.NotNull(reader);
+ Assert.Equal("my-session", reader.SessionId);
+ Assert.Equal("my-stream", reader.StreamId);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_ReturnsEventsInOrder()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write multiple events
+ var event1 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method1" }), CancellationToken);
+ var event2 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method2" }), CancellationToken);
+ var event3 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method3" }), CancellationToken);
+
+ // Create a reader starting from before the first event (use a fake event ID with sequence 0)
+ var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+ var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Assert - Events should be in order
+ Assert.Equal(3, events.Count);
+ Assert.Equal(event1.EventId, events[0].EventId);
+ Assert.Equal(event2.EventId, events[1].EventId);
+ Assert.Equal(event3.EventId, events[2].EventId);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_ReturnsEmpty_WhenNoNewEventsExist()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write one event
+ var writtenItem = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Create a reader starting from the last event (so there are no new events to read)
+ var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_PreservesCorrectDataEventTypeAndEventId()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var message = new JsonRpcNotification { Method = "test/method" };
+ var writtenItem = await writer.WriteEventAsync(new SseItem(message, "custom-event-type"), CancellationToken);
+
+ // Create a reader starting from before the event
+ var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+ var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events);
+ var readEvent = events[0];
+ Assert.Equal(writtenItem.EventId, readEvent.EventId);
+ Assert.Equal("custom-event-type", readEvent.EventType);
+
+ var readMessage = Assert.IsType(readEvent.Data);
+ Assert.Equal("test/method", readMessage.Method);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_HandlesNullData()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var writtenItem = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Create a reader starting from before the event
+ var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+ var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events);
+ Assert.Null(events[0].Data);
+ Assert.Equal(writtenItem.EventId, events[0].EventId);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InPollingMode_CompletesImmediatelyAfterReturningAvailableEvents()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write events
+ await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Create a reader from sequence 0
+ var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+ var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Should complete quickly without waiting for new events
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+ stopwatch.Stop();
+
+ // Assert - Should have returned both events and completed quickly
+ Assert.Equal(2, events.Count);
+ Assert.True(stopwatch.ElapsedMilliseconds < 500, $"Polling mode should complete quickly, took {stopwatch.ElapsedMilliseconds}ms");
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InPollingMode_ReturnsOnlyEventsAfterLastEventId()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write 3 events
+ var event1 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event2 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event3 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Create a reader starting from event2 (should only return event3)
+ var reader = await store.GetStreamReaderAsync(event2.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Assert - Only event3 should be returned
+ Assert.Single(events);
+ Assert.Equal(event3.EventId, events[0].EventId);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InPollingMode_ReturnsEmptyIfNoNewEvents()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write one event and create a reader from that event (no events after it)
+ var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Assert - No new events should be returned
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InPollingMode_DoesNotWaitForNewEvents()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write one event so we have a valid event ID, then create reader from it
+ var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Should complete immediately without waiting (no new events after the one we started from)
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+ stopwatch.Stop();
+
+ // Assert - Should complete quickly with no events
+ Assert.Empty(events);
+ Assert.True(stopwatch.ElapsedMilliseconds < 500, $"Polling mode should complete quickly, took {stopwatch.ElapsedMilliseconds}ms");
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InStreamingMode_WaitsForNewEvents()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50)
+ });
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write one event so we have a valid event ID
+ var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Start reading and then write a new event
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(10));
+ var events = new List>();
+ var readTask = Task.Run(async () =>
+ {
+ await foreach (var evt in reader.ReadEventsAsync(cts.Token))
+ {
+ events.Add(evt);
+ if (events.Count >= 1)
+ {
+ // Got the event we were waiting for, cancel to stop
+ await cts.CancelAsync();
+ }
+ }
+ }, CancellationToken);
+
+ // Write a new event - the reader should pick it up since it's in streaming mode
+ // and won't complete until cancelled or the stream is disposed
+ var newEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Wait for read to complete (either event received or timeout)
+ try
+ {
+ await readTask;
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when we cancel after receiving event
+ }
+
+ // Assert - Should have received the new event
+ Assert.Single(events);
+ Assert.Equal(newEvent.EventId, events[0].EventId);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InStreamingMode_YieldsNewlyWrittenEvents()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50)
+ });
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write initial event
+ var initialEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var reader = await store.GetStreamReaderAsync(initialEvent.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Write multiple events while reader is active
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(10));
+ var events = new List>();
+ var readTask = Task.Run(async () =>
+ {
+ await foreach (var evt in reader.ReadEventsAsync(cts.Token))
+ {
+ events.Add(evt);
+ if (events.Count >= 3)
+ {
+ await cts.CancelAsync();
+ }
+ }
+ }, CancellationToken);
+
+ // Write 3 new events - the reader should pick them up since it's in streaming mode
+ var event1 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event2 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event3 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ try
+ {
+ await readTask;
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected
+ }
+
+ // Assert - Should have received all 3 events in order
+ Assert.Equal(3, events.Count);
+ Assert.Equal(event1.EventId, events[0].EventId);
+ Assert.Equal(event2.EventId, events[1].EventId);
+ Assert.Equal(event3.EventId, events[2].EventId);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InStreamingMode_CompletesWhenStreamIsDisposed()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50)
+ });
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write event to create a valid reader
+ var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Start reading, then dispose the stream
+ var readTask = Task.Run(async () =>
+ {
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ }
+ }, CancellationToken);
+
+ // Dispose the writer - the reader should detect this and exit gracefully
+ await writer.DisposeAsync();
+
+ // Assert - The read should complete gracefully within timeout
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
+ await readTask.WaitAsync(timeoutCts.Token);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_InStreamingMode_RespectsCancellation()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50)
+ });
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write event to create a valid reader
+ var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Start reading and then cancel
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ var events = new List>();
+ var messageReceivedTcs = new TaskCompletionSource();
+ var continueReadingTcs = new TaskCompletionSource();
+ OperationCanceledException? capturedException = null;
+
+ var readTask = Task.Run(async () =>
+ {
+ try
+ {
+ await foreach (var evt in reader.ReadEventsAsync(cts.Token))
+ {
+ events.Add(evt);
+ messageReceivedTcs.SetResult(true);
+ await continueReadingTcs.Task;
+ }
+ }
+ catch (OperationCanceledException ex)
+ {
+ capturedException = ex;
+ }
+ }, CancellationToken);
+
+ // Write a message for the reader to consume
+ await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Wait for the first message to be received
+ await messageReceivedTcs.Task;
+
+ // Cancel so that ReadEventsAsync throws before reading the next message
+ await cts.CancelAsync();
+
+ // Allow the message reader to continue
+ continueReadingTcs.SetResult(true);
+
+ // Wait for read task to complete
+ await readTask;
+
+ Assert.Single(events);
+ Assert.NotNull(capturedException);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_RespectsModeSwitchFromStreamingToPolling()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50)
+ });
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write an event to create a valid reader
+ var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Start reading in streaming mode (will wait for new events)
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(10));
+ var events = new List>();
+ var readCompleted = false;
+
+ var readTask = Task.Run(async () =>
+ {
+ await foreach (var evt in reader.ReadEventsAsync(cts.Token))
+ {
+ events.Add(evt);
+ }
+ readCompleted = true;
+ }, CancellationToken);
+
+ // Switch to polling mode - the reader should detect this and exit
+ await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken);
+
+ // Assert - Read should complete within timeout after switching to polling mode
+ await readTask.WaitAsync(cts.Token);
+ Assert.True(readCompleted);
+ Assert.Empty(events); // No new events were written after the one we used to create the reader
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_PollingModeReturnsEventsThenCompletes()
+ {
+ // Arrange - Start in default mode, write some events, switch to polling, reader should return remaining events
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50)
+ });
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming
+ }, CancellationToken);
+
+ // Write initial event and create reader from sequence 0
+ var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+
+ // Write events first
+ var event1 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event2 = await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Switch to polling mode
+ await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken);
+
+ // Get reader
+ var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Act - Read should return events and complete immediately
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+ stopwatch.Stop();
+
+ // Assert
+ Assert.Equal(2, events.Count);
+ Assert.Equal(event1.EventId, events[0].EventId);
+ Assert.Equal(event2.EventId, events[1].EventId);
+ Assert.True(stopwatch.ElapsedMilliseconds < 500, $"Should complete quickly, took {stopwatch.ElapsedMilliseconds}ms");
+ }
+
+ [Fact]
+ public async Task MultipleStreams_AreIsolated_EventsDoNotLeakBetweenStreams()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+
+ // Create two streams with different session/stream IDs
+ var writer1 = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var writer2 = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-2",
+ StreamId = "stream-2",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write events to each stream
+ var event1 = await writer1.WriteEventAsync(new SseItem(null, "event-from-stream1"), CancellationToken);
+ var event2 = await writer2.WriteEventAsync(new SseItem(null, "event-from-stream2"), CancellationToken);
+
+ // Create readers for each stream from sequence 0
+ var start1 = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+ var start2 = DistributedCacheEventIdFormatter.Format("session-2", "stream-2", 0);
+
+ var reader1 = await store.GetStreamReaderAsync(start1, CancellationToken);
+ var reader2 = await store.GetStreamReaderAsync(start2, CancellationToken);
+ Assert.NotNull(reader1);
+ Assert.NotNull(reader2);
+
+ // Act - Read from each reader
+ var events1 = new List>();
+ await foreach (var evt in reader1.ReadEventsAsync(CancellationToken))
+ {
+ events1.Add(evt);
+ }
+
+ var events2 = new List>();
+ await foreach (var evt in reader2.ReadEventsAsync(CancellationToken))
+ {
+ events2.Add(evt);
+ }
+
+ // Assert - Each reader should only see its own stream's events
+ Assert.Single(events1);
+ Assert.Equal("event-from-stream1", events1[0].EventType);
+ Assert.Equal(event1.EventId, events1[0].EventId);
+
+ Assert.Single(events2);
+ Assert.Equal("event-from-stream2", events2[0].EventType);
+ Assert.Equal(event2.EventId, events2[0].EventId);
+ }
+
+ [Fact]
+ public async Task MultipleStreams_SameSession_DifferentStreamIds_AreIsolated()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+
+ // Create two streams with same session but different stream IDs
+ var writer1 = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "shared-session",
+ StreamId = "stream-A",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var writer2 = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "shared-session",
+ StreamId = "stream-B",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Write events to each stream
+ await writer1.WriteEventAsync(new SseItem(null, "from-A"), CancellationToken);
+ await writer2.WriteEventAsync(new SseItem(null, "from-B"), CancellationToken);
+
+ // Create readers from sequence 0
+ var reader1 = await store.GetStreamReaderAsync(DistributedCacheEventIdFormatter.Format("shared-session", "stream-A", 0), CancellationToken);
+ var reader2 = await store.GetStreamReaderAsync(DistributedCacheEventIdFormatter.Format("shared-session", "stream-B", 0), CancellationToken);
+ Assert.NotNull(reader1);
+ Assert.NotNull(reader2);
+
+ // Act
+ var events1 = new List>();
+ await foreach (var evt in reader1.ReadEventsAsync(CancellationToken))
+ {
+ events1.Add(evt);
+ }
+
+ var events2 = new List>();
+ await foreach (var evt in reader2.ReadEventsAsync(CancellationToken))
+ {
+ events2.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events1);
+ Assert.Equal("from-A", events1[0].EventType);
+
+ Assert.Single(events2);
+ Assert.Equal("from-B", events2[0].EventType);
+ }
+
+ [Fact]
+ public async Task EventIds_AreGloballyUnique_AcrossStreams()
+ {
+ // Arrange
+ var cache = CreateMemoryCache();
+ var store = new DistributedCacheEventStreamStore(cache);
+
+ var writer1 = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var writer2 = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-2",
+ StreamId = "stream-2",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Act - Write events to each stream
+ var event1a = await writer1.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event1b = await writer1.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event2a = await writer2.WriteEventAsync(new SseItem(null), CancellationToken);
+ var event2b = await writer2.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Assert - All event IDs should be unique
+ var allEventIds = new[] { event1a.EventId, event1b.EventId, event2a.EventId, event2b.EventId };
+ Assert.Equal(4, allEventIds.Distinct().Count());
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_UsesConfiguredSlidingExpiration()
+ {
+ // Arrange
+ var mockCache = new TestDistributedCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ EventSlidingExpiration = TimeSpan.FromMinutes(30)
+ };
+ var store = new DistributedCacheEventStreamStore(mockCache, customOptions);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ mockCache.SetCalls.Clear();
+
+ // Act
+ await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Assert - Event should be written with the configured sliding expiration
+ Assert.Contains(mockCache.SetCalls, call =>
+ call.Key.Contains("event:") &&
+ call.Options.SlidingExpiration == TimeSpan.FromMinutes(30));
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_UsesConfiguredAbsoluteExpiration()
+ {
+ // Arrange
+ var mockCache = new TestDistributedCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ EventAbsoluteExpiration = TimeSpan.FromHours(6)
+ };
+ var store = new DistributedCacheEventStreamStore(mockCache, customOptions);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ mockCache.SetCalls.Clear();
+
+ // Act
+ await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Assert - Event should be written with the configured absolute expiration (relative to now)
+ var eventCall = mockCache.SetCalls.FirstOrDefault(call => call.Key.Contains("event:"));
+ Assert.NotNull(eventCall.Key);
+ Assert.NotNull(eventCall.Options.AbsoluteExpirationRelativeToNow);
+ Assert.Equal(TimeSpan.FromHours(6), eventCall.Options.AbsoluteExpirationRelativeToNow);
+ }
+
+ [Fact]
+ public async Task WriteEventAsync_UsesConfiguredMetadataExpiration()
+ {
+ // Arrange - Metadata is written when events are written
+ var mockCache = new TestDistributedCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ MetadataSlidingExpiration = TimeSpan.FromMinutes(45),
+ MetadataAbsoluteExpiration = TimeSpan.FromHours(12)
+ };
+ var store = new DistributedCacheEventStreamStore(mockCache, customOptions);
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ // Act - Write an event, which also updates metadata
+ await writer.WriteEventAsync(new SseItem(null), CancellationToken);
+
+ // Assert
+ var metadataCall = mockCache.SetCalls.FirstOrDefault(call => call.Key.Contains("meta:"));
+ Assert.NotNull(metadataCall.Key);
+ Assert.Equal(TimeSpan.FromMinutes(45), metadataCall.Options.SlidingExpiration);
+ Assert.Equal(TimeSpan.FromHours(12), metadataCall.Options.AbsoluteExpirationRelativeToNow);
+ }
+
+ [Fact]
+ public void DefaultOptions_HaveReasonableDefaults()
+ {
+ // Arrange & Act
+ var options = new DistributedCacheEventStreamStoreOptions();
+
+ // Assert - Check that defaults are set reasonably
+ Assert.True(options.StreamReaderPollingInterval >= TimeSpan.FromMilliseconds(50), "Polling interval should be at least 50ms");
+ Assert.True(options.EventSlidingExpiration > TimeSpan.Zero, "Event sliding expiration should be positive");
+ Assert.True(options.EventAbsoluteExpiration > TimeSpan.Zero, "Event absolute expiration should be positive");
+ Assert.True(options.MetadataSlidingExpiration > TimeSpan.Zero, "Metadata sliding expiration should be positive");
+ Assert.True(options.MetadataAbsoluteExpiration > TimeSpan.Zero, "Metadata absolute expiration should be positive");
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_ThrowsMcpException_WhenMetadataExpires()
+ {
+ // Arrange - Use a cache that allows us to simulate metadata expiration
+ var trackingCache = new TestDistributedCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(10) // Fast polling to detect the bug quickly
+ };
+ var store = new DistributedCacheEventStreamStore(trackingCache, customOptions);
+
+ // Create a stream and write an event
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Streaming // Non-polling mode to trigger the waiting loop
+ }, CancellationToken);
+
+ var item = new SseItem(new JsonRpcNotification { Method = "test" });
+ var writtenItem = await writer.WriteEventAsync(item, CancellationToken);
+
+ // Get a reader starting after the first event (so it will wait for more events)
+ var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Now simulate metadata expiration
+ trackingCache.ExpireMetadata();
+
+ // Act & Assert - Reader should throw McpException when metadata expires
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ // Should not yield any events before throwing
+ }
+ });
+
+ Assert.Contains("session-1", exception.Message);
+ Assert.Contains("stream-1", exception.Message);
+ Assert.Contains("metadata", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_ThrowsMcpException_WhenEventExpires()
+ {
+ // Arrange - Use a cache that allows us to simulate event expiration
+ var trackingCache = new TestDistributedCache();
+ var store = new DistributedCacheEventStreamStore(trackingCache);
+
+ // Create a stream and write multiple events
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var event1 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method1" }), CancellationToken);
+ var event2 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method2" }), CancellationToken);
+ var event3 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method3" }), CancellationToken);
+
+ // Create a reader starting from before the first event
+ var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+ var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken);
+ Assert.NotNull(reader);
+
+ // Simulate event2 expiring from the cache
+ trackingCache.ExpireEvent(event2.EventId!);
+
+ // Act & Assert - Reader should throw McpException when an event is missing
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+ });
+
+ Assert.Contains(event2.EventId!, exception.Message);
+ Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ReadEventsAsync_DoesNotReadMetadata_InPollingMode()
+ {
+ // Arrange - Use a tracking cache to count metadata reads
+ var trackingCache = new TestDistributedCache();
+ var customOptions = new DistributedCacheEventStreamStoreOptions
+ {
+ StreamReaderPollingInterval = TimeSpan.FromMilliseconds(10)
+ };
+ var store = new DistributedCacheEventStreamStore(trackingCache, customOptions);
+
+ // Create a stream in POLLING mode - this allows the reader to exit after reading available events
+ var writer = await store.CreateStreamAsync(new SseEventStreamOptions
+ {
+ SessionId = "session-1",
+ StreamId = "stream-1",
+ Mode = SseEventStreamMode.Polling
+ }, CancellationToken);
+
+ var item1 = new SseItem(new JsonRpcNotification { Method = "test1" });
+ var item2 = new SseItem(new JsonRpcNotification { Method = "test2" });
+ await writer.WriteEventAsync(item1, CancellationToken);
+ await writer.WriteEventAsync(item2, CancellationToken);
+
+ // Get a reader starting before all events (use a fake event ID at sequence 0)
+ var zeroSequenceEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0);
+ var reader = await store.GetStreamReaderAsync(zeroSequenceEventId, CancellationToken);
+ Assert.NotNull(reader);
+
+ // GetStreamReaderAsync should have read metadata exactly once
+ Assert.Equal(1, trackingCache.MetadataReadCount);
+
+ // Act - Read all events
+ var events = new List>();
+ await foreach (var evt in reader.ReadEventsAsync(CancellationToken))
+ {
+ events.Add(evt);
+ }
+
+ // Assert - In polling mode, the reader should:
+ // 1. Use initial metadata from GetStreamReaderAsync (no additional read needed)
+ // 2. Read all available events (2 events)
+ // 3. Exit immediately because mode is Polling
+ //
+ // Metadata read count should remain at 1 (only the initial read from GetStreamReaderAsync)
+ Assert.Equal(2, events.Count);
+ Assert.Equal(1, trackingCache.MetadataReadCount);
+ }
+
+ [Fact]
+ public void EventIdFormatter_Format_CreatesValidEventId()
+ {
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 42);
+
+ // Assert
+ Assert.NotNull(eventId);
+ Assert.NotEmpty(eventId);
+ Assert.Contains(":", eventId); // Should contain separators
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_RoundTripsSuccessfully()
+ {
+ // Arrange
+ var originalSessionId = "my-session-id";
+ var originalStreamId = "my-stream-id";
+ var originalSequence = 12345L;
+
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence);
+ var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence);
+
+ // Assert
+ Assert.True(parsed);
+ Assert.Equal(originalSessionId, sessionId);
+ Assert.Equal(originalStreamId, streamId);
+ Assert.Equal(originalSequence, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_HandlesEmptySessionAndStreamIds()
+ {
+ // Arrange
+ var originalSessionId = "";
+ var originalStreamId = "";
+ var originalSequence = 42L;
+
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence);
+ var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence);
+
+ // Assert
+ Assert.True(parsed);
+ Assert.Equal(originalSessionId, sessionId);
+ Assert.Equal(originalStreamId, streamId);
+ Assert.Equal(originalSequence, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_HandlesSpecialCharactersInSessionId()
+ {
+ // Arrange - Session IDs can contain any visible ASCII character per MCP spec
+ var originalSessionId = "session:with:colons:and|pipes";
+ var originalStreamId = "stream-1";
+ var originalSequence = 1L;
+
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence);
+ var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence);
+
+ // Assert
+ Assert.True(parsed);
+ Assert.Equal(originalSessionId, sessionId);
+ Assert.Equal(originalStreamId, streamId);
+ Assert.Equal(originalSequence, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_HandlesSpecialCharactersInStreamId()
+ {
+ // Arrange
+ var originalSessionId = "session-1";
+ var originalStreamId = "stream:with:colons:and|special!chars@#$%";
+ var originalSequence = 1L;
+
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence);
+ var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence);
+
+ // Assert
+ Assert.True(parsed);
+ Assert.Equal(originalSessionId, sessionId);
+ Assert.Equal(originalStreamId, streamId);
+ Assert.Equal(originalSequence, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_HandlesUnicodeCharacters()
+ {
+ // Arrange
+ var originalSessionId = "session-日本語-émojis-🎉";
+ var originalStreamId = "stream-中文-العربية";
+ var originalSequence = 999L;
+
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence);
+ var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence);
+
+ // Assert
+ Assert.True(parsed);
+ Assert.Equal(originalSessionId, sessionId);
+ Assert.Equal(originalStreamId, streamId);
+ Assert.Equal(originalSequence, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_HandlesZeroSequence()
+ {
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format("session", "stream", 0);
+ var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out _, out _, out var sequence);
+
+ // Assert
+ Assert.True(parsed);
+ Assert.Equal(0, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_HandlesLargeSequence()
+ {
+ // Act
+ var eventId = DistributedCacheEventIdFormatter.Format("session", "stream", long.MaxValue);
+ var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out _, out _, out var sequence);
+
+ // Assert
+ Assert.True(parsed);
+ Assert.Equal(long.MaxValue, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_ReturnsFalse_ForEmptyString()
+ {
+ // Act
+ var parsed = DistributedCacheEventIdFormatter.TryParse("", out var sessionId, out var streamId, out var sequence);
+
+ // Assert
+ Assert.False(parsed);
+ Assert.Equal(string.Empty, sessionId);
+ Assert.Equal(string.Empty, streamId);
+ Assert.Equal(0, sequence);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_ReturnsFalse_ForInvalidFormat()
+ {
+ // Act & Assert - Various invalid formats
+ Assert.False(DistributedCacheEventIdFormatter.TryParse("no-separators", out _, out _, out _));
+ Assert.False(DistributedCacheEventIdFormatter.TryParse("only:one", out _, out _, out _));
+ Assert.False(DistributedCacheEventIdFormatter.TryParse("too:many:parts:here", out _, out _, out _));
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_ReturnsFalse_ForInvalidBase64()
+ {
+ // Act - Invalid base64 in first part
+ var parsed = DistributedCacheEventIdFormatter.TryParse("!!!invalid!!!:c3RyZWFt:1", out _, out _, out _);
+
+ // Assert
+ Assert.False(parsed);
+ }
+
+ [Fact]
+ public void EventIdFormatter_TryParse_ReturnsFalse_ForNonNumericSequence()
+ {
+ // Arrange - Valid base64 but non-numeric sequence
+ var sessionBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("session"));
+ var streamBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("stream"));
+ var invalidEventId = $"{sessionBase64}:{streamBase64}:not-a-number";
+
+ // Act
+ var parsed = DistributedCacheEventIdFormatter.TryParse(invalidEventId, out _, out _, out _);
+
+ // Assert
+ Assert.False(parsed);
+ }
+
+ ///
+ /// A distributed cache that tracks all operations for verification in tests.
+ /// Supports tracking Set calls, counting metadata reads, and simulating metadata/event expiration.
+ ///
+ private sealed class TestDistributedCache : IDistributedCache
+ {
+ private readonly MemoryDistributedCache _innerCache = new(Options.Create(new MemoryDistributedCacheOptions()));
+ private int _metadataReadCount;
+ private bool _metadataExpired;
+ private readonly HashSet _expiredEventIds = [];
+
+ public List<(string Key, DistributedCacheEntryOptions Options)> SetCalls { get; } = [];
+ public int MetadataReadCount => _metadataReadCount;
+
+ public void ExpireMetadata() => _metadataExpired = true;
+ public void ExpireEvent(string eventId) => _expiredEventIds.Add(eventId);
+
+ public byte[]? Get(string key)
+ {
+ if (key.Contains("meta:"))
+ {
+ Interlocked.Increment(ref _metadataReadCount);
+ if (_metadataExpired)
+ {
+ return null;
+ }
+ }
+ if (IsExpiredEvent(key))
+ {
+ return null;
+ }
+ return _innerCache.Get(key);
+ }
+
+ public Task GetAsync(string key, CancellationToken token = default)
+ {
+ if (key.Contains("meta:"))
+ {
+ Interlocked.Increment(ref _metadataReadCount);
+ if (_metadataExpired)
+ {
+ return Task.FromResult(null);
+ }
+ }
+ if (IsExpiredEvent(key))
+ {
+ return Task.FromResult(null);
+ }
+ return _innerCache.GetAsync(key, token);
+ }
+
+ private bool IsExpiredEvent(string key)
+ {
+ // Cache key format is "mcp:sse:event:{eventId}"
+ foreach (var expiredEventId in _expiredEventIds)
+ {
+ if (key.EndsWith(expiredEventId))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void Refresh(string key) => _innerCache.Refresh(key);
+ public Task RefreshAsync(string key, CancellationToken token = default) => _innerCache.RefreshAsync(key, token);
+ public void Remove(string key) => _innerCache.Remove(key);
+ public Task RemoveAsync(string key, CancellationToken token = default) => _innerCache.RemoveAsync(key, token);
+
+ public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
+ {
+ SetCalls.Add((key, options));
+ _innerCache.Set(key, value, options);
+ }
+
+ public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
+ {
+ SetCalls.Add((key, options));
+ return _innerCache.SetAsync(key, value, options, token);
+ }
+ }
+}
From cfa89a39079985589160826e2174304c81efc8fa Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 3 Feb 2026 15:51:32 -0800
Subject: [PATCH 027/320] Clarify McpMetaAttribute documentation scope (#1242)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Server/McpMetaAttribute.cs | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs
index a70c26c..c629277 100644
--- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs
+++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs
@@ -11,7 +11,15 @@ namespace ModelContextProtocol.Server;
///
///
/// The metadata is used to populate the , ,
-/// or property of the corresponding primitive.
+/// or property of the corresponding primitive. This metadata is
+/// included in the responses to listing operations (tools/list, prompts/list,
+/// resources/list).
+///
+///
+/// This metadata is not propagated to the results of invocation operations such as
+/// tools/call, prompts/get, or resources/read. To include metadata in
+/// those results, set the Meta property on the ,
+/// , or directly in your method implementation.
///
///
/// This attribute can be applied multiple times to a method to specify multiple key/value pairs
From e55ae7b7bdcb956eff98264a4c657071aecb9272 Mon Sep 17 00:00:00 2001
From: Stephen Halter
Date: Thu, 5 Feb 2026 17:42:41 -0800
Subject: [PATCH 028/320] Use package-lock.json to pin conformance test
dependencies (#1247)
---
.github/workflows/ci-build-test.yml | 13 +-
.gitignore | 3 +
Directory.Packages.props | 3 -
package-lock.json | 1274 +++++++++++++++++
package.json | 10 +
tests/Common/Utils/NodeHelpers.cs | 131 +-
.../ClientConformanceTests.cs | 8 +-
...delContextProtocol.AspNetCore.Tests.csproj | 7 -
.../ServerConformanceTests.cs | 21 +-
9 files changed, 1416 insertions(+), 54 deletions(-)
create mode 100644 package-lock.json
create mode 100644 package.json
diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml
index 70e7687..a9bf1a6 100644
--- a/.github/workflows/ci-build-test.yml
+++ b/.github/workflows/ci-build-test.yml
@@ -16,6 +16,8 @@ on:
- "*.props"
- "Makefile"
- "global.json"
+ - "package.json"
+ - "package-lock.json"
- "src/**"
- "tests/**"
- "samples/**"
@@ -52,15 +54,8 @@ jobs:
with:
node-version: '20'
- - name: 📦 Install dependencies for tests
- run: npm install @modelcontextprotocol/server-everything@2025.12.18
-
- - name: 📦 Install dependencies for tests
- run: npm install @modelcontextprotocol/server-memory
-
- # Keep version in sync with McpConformanceVersion in Directory.Packages.props
- - name: 📦 Install conformance test runner
- run: npm install @modelcontextprotocol/conformance@0.1.10
+ - name: 📦 Install pinned npm dependencies for tests
+ run: npm ci
- name: 🏗️ Build
run: make build CONFIGURATION=${{ matrix.configuration }}
diff --git a/.gitignore b/.gitignore
index 171615f..8d8db2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,9 @@ _ReSharper*/
**/packages/*
!**/packages/build/
+# npm
+node_modules/
+
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 044adfd..8a74f47 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,9 +5,6 @@
9.0.11
10.0.2
10.2.0
-
- 0.1.10
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..c97353f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1274 @@
+{
+ "name": "csharp-sdk",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@modelcontextprotocol/conformance": "0.1.10",
+ "@modelcontextprotocol/sdk": "1.25.3",
+ "@modelcontextprotocol/server-everything": "2025.12.18",
+ "@modelcontextprotocol/server-memory": "2026.1.26"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "1.19.9",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
+ "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@modelcontextprotocol/conformance": {
+ "version": "0.1.10",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.10.tgz",
+ "integrity": "sha512-efzLxW3sNiC48ARADxNkSNSZREdjtQNJ+12MJHCUDSnHZMbiFa7v5SivB2Aja4LCE0ZiWgnG5I5PSNMBrp1xKg==",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.25.2",
+ "commander": "^14.0.2",
+ "eventsource-parser": "^3.0.6",
+ "express": "^5.1.0",
+ "jose": "^6.1.2",
+ "zod": "^3.25.76"
+ },
+ "bin": {
+ "conformance": "dist/index.js"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.25.3",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz",
+ "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.0.1",
+ "express-rate-limit": "^7.5.0",
+ "jose": "^6.1.1",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@modelcontextprotocol/server-everything": {
+ "version": "2025.12.18",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-everything/-/server-everything-2025.12.18.tgz",
+ "integrity": "sha512-S+MYITzTp02eLSxT+abAMDXf2vjubVKUbdjVj5HW9aEepa/lkcdcSZHeI8PpxpnZOQOW28FKGyFEG/QdawyAeA==",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.24.0",
+ "cors": "^2.8.5",
+ "express": "^5.2.1",
+ "jszip": "^3.10.1",
+ "zod": "^3.25.0",
+ "zod-to-json-schema": "^3.23.5"
+ },
+ "bin": {
+ "mcp-server-everything": "dist/index.js"
+ }
+ },
+ "node_modules/@modelcontextprotocol/server-memory": {
+ "version": "2026.1.26",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-memory/-/server-memory-2026.1.26.tgz",
+ "integrity": "sha512-7F0hbaEB4lVqkYhNWmrC5jJjEWPCofgXd7OIk3h97HyvJL6aTAhlUNYaH8lCDxAzlK9sr2pLCkZEYI+m4HSOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.25.2"
+ },
+ "bin": {
+ "mcp-server-memory": "dist/index.js"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.11.7",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
+ "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.1",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
+ "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25 || ^4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1368d15
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "private": true,
+ "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
+ "dependencies": {
+ "@modelcontextprotocol/conformance": "0.1.10",
+ "@modelcontextprotocol/sdk": "1.25.3",
+ "@modelcontextprotocol/server-everything": "2025.12.18",
+ "@modelcontextprotocol/server-memory": "2026.1.26"
+ }
+}
diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs
index 70e02d6..9bc0acf 100644
--- a/tests/Common/Utils/NodeHelpers.cs
+++ b/tests/Common/Utils/NodeHelpers.cs
@@ -8,20 +8,88 @@ namespace ModelContextProtocol.Tests.Utils;
///
public static class NodeHelpers
{
+ private static readonly object _npmInstallLock = new();
+ private static bool _npmInstallCompleted;
+
///
- /// Creates a ProcessStartInfo configured to run npx with the specified arguments.
+ /// Finds the repository root by searching for package-lock.json in ancestor directories.
///
- /// The arguments to pass to npx.
- /// A configured ProcessStartInfo for running npx.
- public static ProcessStartInfo NpxStartInfo(string arguments)
+ private static string FindRepoRoot()
{
+ var dir = AppContext.BaseDirectory;
+ while (dir is not null)
+ {
+ if (File.Exists(Path.Combine(dir, "package-lock.json")))
+ {
+ return dir;
+ }
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ throw new InvalidOperationException("Could not find repository root (no package-lock.json found in ancestor directories).");
+ }
+
+ ///
+ /// Ensures npm dependencies are installed by running 'npm ci' from the repo root.
+ /// This is safe to call multiple times; it only runs once per test process.
+ ///
+ public static void EnsureNpmDependenciesInstalled()
+ {
+ if (_npmInstallCompleted)
+ {
+ return;
+ }
+
+ lock (_npmInstallLock)
+ {
+ if (_npmInstallCompleted)
+ {
+ return;
+ }
+
+ var repoRoot = FindRepoRoot();
+ var nodeModulesPath = Path.Combine(repoRoot, "node_modules");
+
+ // Use 'npm ci' if node_modules doesn't exist, otherwise assume it's up to date.
+ if (!Directory.Exists(nodeModulesPath))
+ {
+ var startInfo = NpmStartInfo("ci", repoRoot);
+ using var process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("Failed to start 'npm ci'.");
+ process.WaitForExit(120_000);
+
+ if (process.ExitCode != 0)
+ {
+ var error = process.StandardError.ReadToEnd();
+ throw new InvalidOperationException($"'npm ci' failed with exit code {process.ExitCode}: {error}");
+ }
+ }
+
+ _npmInstallCompleted = true;
+ }
+ }
+
+ ///
+ /// Creates a ProcessStartInfo configured to run a binary from node_modules/.bin/conformance.
+ /// Calls first.
+ ///
+ /// The name of the binary in node_modules/.bin (e.g. "conformance").
+ /// The arguments to pass to the binary.
+ /// A configured ProcessStartInfo for running the binary.
+ public static ProcessStartInfo ConformanceTestStartInfo(string arguments)
+ {
+ EnsureNpmDependenciesInstalled();
+
+ var repoRoot = FindRepoRoot();
+ var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- // On Windows, npx is a PowerShell script, so we need to use cmd.exe to invoke it
+ // On Windows, node_modules/.bin contains .cmd shims that can be executed directly
return new ProcessStartInfo
{
- FileName = "cmd.exe",
- Arguments = $"/c npx {arguments}",
+ FileName = $"{binPath}.cmd",
+ Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@@ -30,10 +98,9 @@ public static class NodeHelpers
}
else
{
- // On Unix-like systems, npx is typically a shell script that can be executed directly
return new ProcessStartInfo
{
- FileName = "npx",
+ FileName = binPath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
@@ -44,16 +111,22 @@ public static class NodeHelpers
}
///
- /// Checks if Node.js and npx are installed and available on the system.
+ /// Checks if Node.js is installed and available on the system.
///
- /// True if npx is available, false otherwise.
- public static bool IsNpxInstalled()
+ public static bool IsNodeInstalled()
{
try
{
- var startInfo = NpxStartInfo("--version");
+ using var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = "node",
+ Arguments = "--version",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ });
- using var process = Process.Start(startInfo);
if (process == null)
{
return false;
@@ -67,4 +140,34 @@ public static class NodeHelpers
return false;
}
}
+
+ private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return new ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = $"/c npm {arguments}",
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ }
+ else
+ {
+ return new ProcessStartInfo
+ {
+ FileName = "npm",
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ }
+ }
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
index 7a92cd6..fbcbe89 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
@@ -9,19 +9,19 @@ namespace ModelContextProtocol.ConformanceTests;
/// This test runs the Node.js-based conformance test suite for the client
/// and reports the results.
///
-public class ClientConformanceTests //: IAsyncLifetime
+public class ClientConformanceTests
{
private readonly ITestOutputHelper _output;
// Public static property required for SkipUnless attribute
- public static bool IsNpxInstalled => NodeHelpers.IsNpxInstalled();
+ public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled();
public ClientConformanceTests(ITestOutputHelper output)
{
_output = output;
}
- [Theory(Skip = "npx is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNpxInstalled))]
+ [Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
[InlineData("initialize")]
[InlineData("tools_call")]
[InlineData("auth/metadata-default")]
@@ -59,7 +59,7 @@ public class ClientConformanceTests //: IAsyncLifetime
$"ConformanceClient executable not found at: {conformanceClientPath}");
}
- var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance client --scenario {scenario} --command \"{conformanceClientPath} {scenario}\"");
+ var startInfo = NodeHelpers.ConformanceTestStartInfo($"client --scenario {scenario} --command \"{conformanceClientPath} {scenario}\"");
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj
index 02ce7de..384e8fc 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj
@@ -52,13 +52,6 @@
-
-
- <_Parameter1>McpConformanceVersion
- <_Parameter2>$(McpConformanceVersion)
-
-
-
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index 02a98b8..b0cac7c 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -97,11 +97,10 @@ public class ServerConformanceTests : IAsyncLifetime
[Fact]
public async Task RunConformanceTests()
{
- // Check if Node.js is installed
- Assert.SkipWhen(!NodeHelpers.IsNpxInstalled(), "Node.js is not installed. Skipping conformance tests.");
+ Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
// Run the conformance test suite
- var result = await RunNpxConformanceTests();
+ var result = await RunConformanceTestsAsync();
// Report the results
Assert.True(result.Success,
@@ -115,21 +114,9 @@ public class ServerConformanceTests : IAsyncLifetime
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
}
- private static string GetConformanceVersion()
+ private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync()
{
- var assembly = typeof(ServerConformanceTests).Assembly;
- var attribute = assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyMetadataAttribute), false)
- .Cast()
- .FirstOrDefault(a => a.Key == "McpConformanceVersion");
- return attribute?.Value ?? throw new InvalidOperationException("McpConformanceVersion not found in assembly metadata");
- }
-
- private async Task<(bool Success, string Output, string Error)> RunNpxConformanceTests()
- {
- // Version is configured in Directory.Packages.props for central management
- var version = GetConformanceVersion();
-
- var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance@{version} server --url {_serverUrl}");
+ var startInfo = NodeHelpers.ConformanceTestStartInfo($"server --url {_serverUrl}");
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
From 0114de746e4b9437dea60eca22c1c603ff1aba51 Mon Sep 17 00:00:00 2001
From: Stephen Toub
Date: Thu, 5 Feb 2026 21:18:47 -0500
Subject: [PATCH 029/320] Update to MCP conformance tests version 0.1.13
(#1249)
---
package-lock.json | 73 ++++++++++++++-----
package.json | 3 +-
.../Tools/ConformanceTools.cs | 50 ++++++++++---
.../appsettings.json | 2 +-
4 files changed, 98 insertions(+), 30 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index c97353f..5a2d798 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,11 @@
{
- "name": "csharp-sdk",
+ "name": "mcp",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
- "@modelcontextprotocol/conformance": "0.1.10",
- "@modelcontextprotocol/sdk": "1.25.3",
+ "@modelcontextprotocol/conformance": "0.1.13",
"@modelcontextprotocol/server-everything": "2025.12.18",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
@@ -24,16 +23,18 @@
}
},
"node_modules/@modelcontextprotocol/conformance": {
- "version": "0.1.10",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.10.tgz",
- "integrity": "sha512-efzLxW3sNiC48ARADxNkSNSZREdjtQNJ+12MJHCUDSnHZMbiFa7v5SivB2Aja4LCE0ZiWgnG5I5PSNMBrp1xKg==",
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.13.tgz",
+ "integrity": "sha512-J9cC7Dsi7hTn7sxorvxDZ+wC8OlHAkOmVxVm1/IBQ8HEpYJu8r0eAUEw68vTmjBPP5WcU8Jld4RZ3cOFp/Ih1g==",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.25.2",
+ "@modelcontextprotocol/sdk": "^1.26.0",
"commander": "^14.0.2",
"eventsource-parser": "^3.0.6",
"express": "^5.1.0",
"jose": "^6.1.2",
+ "undici": "^7.19.0",
+ "yaml": "^2.8.2",
"zod": "^3.25.76"
},
"bin": {
@@ -41,9 +42,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.25.3",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz",
- "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==",
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
+ "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
@@ -54,14 +55,15 @@
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
- "express": "^5.0.1",
- "express-rate-limit": "^7.5.0",
- "jose": "^6.1.1",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
- "zod-to-json-schema": "^3.25.0"
+ "zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
@@ -467,10 +469,13 @@
}
},
"node_modules/express-rate-limit": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
- "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
+ "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
+ "dependencies": {
+ "ip-address": "10.0.1"
+ },
"engines": {
"node": ">= 16"
},
@@ -629,7 +634,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -682,6 +686,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1207,6 +1220,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/undici": {
+ "version": "7.20.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz",
+ "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1252,6 +1274,21 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
diff --git a/package.json b/package.json
index 1368d15..4d007f0 100644
--- a/package.json
+++ b/package.json
@@ -2,8 +2,7 @@
"private": true,
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
"dependencies": {
- "@modelcontextprotocol/conformance": "0.1.10",
- "@modelcontextprotocol/sdk": "1.25.3",
+ "@modelcontextprotocol/conformance": "0.1.13",
"@modelcontextprotocol/server-everything": "2025.12.18",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs
index 177de5c..0328259 100644
--- a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs
+++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs
@@ -332,16 +332,49 @@ public class ConformanceTools
{
Properties =
{
- ["color"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema()
+ ["untitledSingle"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema()
{
- Description = "Choose a color",
- Enum = ["red", "green", "blue"]
+ Description = "Choose an option",
+ Enum = ["option1", "option2", "option3"]
},
- ["size"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema()
+ ["titledSingle"] = new ElicitRequestParams.TitledSingleSelectEnumSchema()
{
- Description = "Choose a size",
- Enum = ["small", "medium", "large"],
- Default = "medium"
+ Description = "Choose a titled option",
+ OneOf =
+ [
+ new() { Const = "value1", Title = "First Option" },
+ new() { Const = "value2", Title = "Second Option" },
+ new() { Const = "value3", Title = "Third Option" }
+ ]
+ },
+#pragma warning disable MCP9001
+ ["legacyEnum"] = new ElicitRequestParams.LegacyTitledEnumSchema()
+ {
+ Description = "Choose a legacy option",
+ Enum = ["opt1", "opt2", "opt3"],
+ EnumNames = ["Option One", "Option Two", "Option Three"]
+ },
+#pragma warning restore MCP9001
+ ["untitledMulti"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema()
+ {
+ Description = "Choose multiple options",
+ Items = new ElicitRequestParams.UntitledEnumItemsSchema
+ {
+ Enum = ["option1", "option2", "option3"]
+ }
+ },
+ ["titledMulti"] = new ElicitRequestParams.TitledMultiSelectEnumSchema()
+ {
+ Description = "Choose multiple titled options",
+ Items = new ElicitRequestParams.TitledEnumItemsSchema
+ {
+ AnyOf =
+ [
+ new() { Const = "value1", Title = "First Choice" },
+ new() { Const = "value2", Title = "Second Choice" },
+ new() { Const = "value3", Title = "Third Choice" }
+ ]
+ }
}
}
};
@@ -354,8 +387,7 @@ public class ConformanceTools
if (result.Action == "accept" && result.Content != null)
{
- return $"Accepted with values: color={result.Content["color"].GetString()}, " +
- $"size={result.Content["size"].GetString()}";
+ return $"Elicitation completed: action={result.Action}, content={result.Content}";
}
else
{
diff --git a/tests/ModelContextProtocol.ConformanceServer/appsettings.json b/tests/ModelContextProtocol.ConformanceServer/appsettings.json
index 10f68b8..757d842 100644
--- a/tests/ModelContextProtocol.ConformanceServer/appsettings.json
+++ b/tests/ModelContextProtocol.ConformanceServer/appsettings.json
@@ -5,5 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "localhost;127.0.0.1;[::1]"
}
From bfd01b996fee723c361254976682be027e11f205 Mon Sep 17 00:00:00 2001
From: Jeff Handley
Date: Fri, 6 Feb 2026 07:48:32 -0800
Subject: [PATCH 030/320] Bump version to 0.9.0-preview.1 (#1245)
Co-authored-by: Stephen Halter
---
src/Directory.Build.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index c7d089c..02a4529 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -5,7 +5,7 @@
https://modelcontextprotocol.github.io/csharp-sdk
https://github.com/modelcontextprotocol/csharp-sdk
git
- 0.8.0
+ 0.9.0
preview.1
ModelContextProtocol
© Anthropic and Contributors.
From 9c1538fd98f171faa61b003e62e94582dbe4a3ed Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Feb 2026 22:00:12 +0200
Subject: [PATCH 031/320] Add AOT compatibility publish test (#1246)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
---
.github/workflows/ci-build-test.yml | 4 +++
Makefile | 4 +++
ModelContextProtocol.slnx | 1 +
...xtProtocol.AotCompatibility.TestApp.csproj | 24 +++++++++++++
.../Program.cs | 36 +++++++++++++++++++
5 files changed, 69 insertions(+)
create mode 100644 tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj
create mode 100644 tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml
index a9bf1a6..b9d6c43 100644
--- a/.github/workflows/ci-build-test.yml
+++ b/.github/workflows/ci-build-test.yml
@@ -63,6 +63,10 @@ jobs:
- name: 🧪 Test
run: make test CONFIGURATION=${{ matrix.configuration }}
+ - name: 🧪 AOT Compatibility
+ if: matrix.configuration == 'Release'
+ run: make test-aot CONFIGURATION=${{ matrix.configuration }}
+
- name: 📦 Pack
if: matrix.configuration == 'Release'
run: make pack CONFIGURATION=${{ matrix.configuration }}
diff --git a/Makefile b/Makefile
index ce3d7f9..993c3dd 100644
--- a/Makefile
+++ b/Makefile
@@ -30,6 +30,10 @@ test: build
-- \
RunConfiguration.CollectSourceInformation=true
+test-aot:
+ dotnet publish tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj --configuration $(CONFIGURATION) -o $(ARTIFACT_PATH)/aot-publish
+ $(ARTIFACT_PATH)/aot-publish/ModelContextProtocol.AotCompatibility.TestApp
+
pack: restore
dotnet pack --no-restore --configuration $(CONFIGURATION)
diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx
index 64e834b..3036f30 100644
--- a/ModelContextProtocol.slnx
+++ b/ModelContextProtocol.slnx
@@ -70,6 +70,7 @@
+
diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj
new file mode 100644
index 0000000..2b505f9
--- /dev/null
+++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net10.0
+
+
+ true
+ false
+
+ $(NoWarn);MCPEXP001
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
new file mode 100644
index 0000000..687d78e
--- /dev/null
+++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.IO.Pipelines;
+
+Pipe clientToServerPipe = new(), serverToClientPipe = new();
+
+// Create a server using a stream-based transport over an in-memory pipe.
+await using McpServer server = McpServer.Create(
+ new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()),
+ new McpServerOptions()
+ {
+ ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })]
+ });
+_ = server.RunAsync();
+
+// Connect a client using a stream-based transport over the same in-memory pipe.
+await using McpClient client = await McpClient.CreateAsync(
+ new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream()));
+
+// List all tools.
+var tools = await client.ListToolsAsync();
+if (tools.Count == 0)
+{
+ throw new Exception("Expected at least one tool.");
+}
+
+// Invoke a tool.
+var echo = tools.First(t => t.Name == "Echo");
+var result = await echo.InvokeAsync(new() { ["arg"] = "Hello World" });
+if (result is null || !result.ToString()!.Contains("Echo: Hello World"))
+{
+ throw new Exception($"Unexpected result: {result}");
+}
+
+Console.WriteLine("Success!");
From 27b5eb80315a6835b78568bafc549ea10bcf03b1 Mon Sep 17 00:00:00 2001
From: Stephen Toub
Date: Tue, 10 Feb 2026 11:33:52 -0500
Subject: [PATCH 032/320] Bring up to 0.1.13 conformance (#1254)
---
.../Authentication/ClientOAuthProvider.cs | 63 ++-
.../Client/McpClientImpl.cs | 9 +-
.../StreamableHttpClientSessionTransport.cs | 28 +-
.../Protocol/ElicitRequestParams.cs | 16 +
.../Protocol/ElicitResult.cs | 30 ++
.../Server/McpServer.Methods.cs | 4 +-
tests/Common/Utils/NodeHelpers.cs | 20 +-
.../ClientConformanceTests.cs | 34 +-
.../ServerConformanceTests.cs | 86 ++--
.../StreamableHttpServerConformanceTests.cs | 20 +-
.../Program.cs | 100 ++++-
.../Program.cs | 26 +-
.../Tools/ConformanceTools.cs | 45 +-
...rverBuilderExtensionsMessageFilterTests.cs | 2 +-
.../ElicitationClientDefaultsTests.cs | 420 ++++++++++++++++++
.../ElicitationServerDefaultsTests.cs | 405 +++++++++++++++++
.../TaskCancellationIntegrationTests.cs | 11 +-
.../Transport/StdioClientTransportTests.cs | 8 +
18 files changed, 1249 insertions(+), 78 deletions(-)
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitationClientDefaultsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitationServerDefaultsTests.cs
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index 30854dc..2b0d345 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -44,6 +44,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private string? _clientId;
private string? _clientSecret;
+ private string? _tokenEndpointAuthMethod;
private ITokenCache _tokenCache;
private AuthorizationServerMetadata? _authServerMetadata;
@@ -293,6 +294,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
}
}
+ // Determine the token endpoint auth method from server metadata if not already set by DCR.
+ _tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();
+
// Store auth server metadata for future refresh operations
_authServerMetadata = authServerMetadata;
@@ -385,20 +389,15 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private async Task RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
{
- var requestContent = new FormUrlEncodedContent(new Dictionary
+ Dictionary formFields = new()
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
- ["client_id"] = GetClientIdOrThrow(),
- ["client_secret"] = _clientSecret ?? string.Empty,
["resource"] = resourceUri.ToString(),
- });
-
- using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
- {
- Content = requestContent
};
+ using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
+
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!httpResponse.IsSuccessStatusCode)
@@ -482,22 +481,17 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
{
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
- var requestContent = new FormUrlEncodedContent(new Dictionary
+ Dictionary formFields = new()
{
["grant_type"] = "authorization_code",
["code"] = authorizationCode,
["redirect_uri"] = _redirectUri.ToString(),
- ["client_id"] = GetClientIdOrThrow(),
["code_verifier"] = codeVerifier,
- ["client_secret"] = _clientSecret ?? string.Empty,
["resource"] = resourceUri.ToString(),
- });
-
- using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
- {
- Content = requestContent
};
+ using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
+
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
@@ -506,6 +500,38 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
return tokens.AccessToken;
}
+ ///
+ /// Creates an HTTP request to the token endpoint, applying the appropriate authentication
+ /// method based on .
+ ///
+ private HttpRequestMessage CreateTokenRequest(Uri tokenEndpoint, Dictionary formFields)
+ {
+ HttpRequestMessage request = new(HttpMethod.Post, tokenEndpoint);
+
+ var clientId = GetClientIdOrThrow();
+ if (string.Equals(_tokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal))
+ {
+ // Per RFC 6749 §2.3.1: send client_id:client_secret as HTTP Basic auth.
+ request.Headers.Authorization = new(
+ "Basic",
+ Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Uri.EscapeDataString(clientId)}:{Uri.EscapeDataString(_clientSecret ?? string.Empty)}")));
+ }
+ else if (string.Equals(_tokenEndpointAuthMethod, "none", StringComparison.Ordinal))
+ {
+ // Public client: include client_id in the body but no secret.
+ formFields["client_id"] = clientId;
+ }
+ else
+ {
+ // Default to client_secret_post: include credentials in the body.
+ formFields["client_id"] = clientId;
+ formFields["client_secret"] = _clientSecret ?? string.Empty;
+ }
+
+ request.Content = new FormUrlEncodedContent(formFields);
+ return request;
+ }
+
private async Task HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
@@ -620,6 +646,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
_clientSecret = registrationResponse.ClientSecret;
}
+ if (!string.IsNullOrEmpty(registrationResponse.TokenEndpointAuthMethod))
+ {
+ _tokenEndpointAuthMethod = registrationResponse.TokenEndpointAuthMethod;
+ }
+
LogDynamicClientRegistrationSuccessful(_clientId!);
if (_dcrResponseDelegate is not null)
diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
index e7728cd..6f4d938 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@@ -197,6 +197,7 @@ internal sealed partial class McpClientImpl : McpClient
async ct =>
{
var result = await elicitationHandler(request, ct).ConfigureAwait(false);
+ result = ElicitResult.WithDefaults(request, result);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult);
},
options.SendTaskStatusNotifications,
@@ -205,6 +206,7 @@ internal sealed partial class McpClientImpl : McpClient
// Normal synchronous execution - serialize result to JsonElement
var elicitResult = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
+ elicitResult = ElicitResult.WithDefaults(request, elicitResult);
return JsonSerializer.SerializeToElement(elicitResult, McpJsonUtilities.JsonContext.Default.ElicitResult);
},
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
@@ -214,7 +216,11 @@ internal sealed partial class McpClientImpl : McpClient
{
requestHandlers.Set(
RequestMethods.ElicitationCreate,
- (request, _, cancellationToken) => elicitationHandler(request, cancellationToken),
+ async (request, _, cancellationToken) =>
+ {
+ var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
+ return ElicitResult.WithDefaults(request, result);
+ },
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
McpJsonUtilities.JsonContext.Default.ElicitResult);
}
@@ -671,4 +677,5 @@ internal sealed partial class McpClientImpl : McpClient
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
private partial void LogClientSessionResumed(string endpointName);
+
}
diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
index c80b6a4..a097cc0 100644
--- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using System.Diagnostics;
using System.Net.Http.Headers;
using System.Net.ServerSentEvents;
using System.Text.Json;
@@ -239,7 +240,19 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
if (shouldDelay)
{
var delay = state.RetryInterval ?? _options.DefaultReconnectionInterval;
- await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+
+ // Subtract time already elapsed since the SSE stream ended to more accurately
+ // honor the retry interval. Without this, processing overhead (HTTP response
+ // disposal, condition checks, etc.) inflates the observed reconnection delay.
+ if (state.StreamEndedTimestamp != 0)
+ {
+ delay -= ElapsedSince(state.StreamEndedTimestamp);
+ }
+
+ if (delay > TimeSpan.Zero)
+ {
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ }
}
shouldDelay = true;
@@ -336,9 +349,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
}
catch (Exception ex) when (ex is IOException or HttpRequestException)
{
+ state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
return new() { IsNetworkError = true };
}
+ state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
return default;
}
@@ -435,6 +450,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
{
public string? LastEventId { get; set; }
public TimeSpan? RetryInterval { get; set; }
+ /// Timestamp (via Stopwatch.GetTimestamp()) when the last SSE stream ended, used to discount processing overhead from the retry delay.
+ public long StreamEndedTimestamp { get; set; }
}
///
@@ -445,4 +462,13 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
public JsonRpcMessageWithId? Response { get; init; }
public bool IsNetworkError { get; init; }
}
+
+ private static TimeSpan ElapsedSince(long stopwatchTimestamp)
+ {
+#if NET
+ return Stopwatch.GetElapsedTime(stopwatchTimestamp);
+#else
+ return TimeSpan.FromSeconds((double)(Stopwatch.GetTimestamp() - stopwatchTimestamp) / Stopwatch.Frequency);
+#endif
+ }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
index 2e50824..91cbb30 100644
--- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
@@ -143,6 +143,22 @@ public sealed class ElicitRequestParams : RequestParams
{
}
+ /// Gets the default value for this schema as a , if one is defined.
+ internal JsonElement? GetDefaultAsJsonElement() => this switch
+ {
+ StringSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
+ NumberSchema { Default: { } n } => JsonSerializer.SerializeToElement(n, McpJsonUtilities.JsonContext.Default.Double),
+ BooleanSchema { Default: { } b } => JsonSerializer.SerializeToElement(b, McpJsonUtilities.JsonContext.Default.Boolean),
+ UntitledSingleSelectEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
+ TitledSingleSelectEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
+ UntitledMultiSelectEnumSchema { Default: { } a } => JsonSerializer.SerializeToElement(a, McpJsonUtilities.JsonContext.Default.IListString),
+ TitledMultiSelectEnumSchema { Default: { } a } => JsonSerializer.SerializeToElement(a, McpJsonUtilities.JsonContext.Default.IListString),
+#pragma warning disable MCP9001 // LegacyTitledEnumSchema is deprecated but supported for backward compatibility
+ LegacyTitledEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
+#pragma warning restore MCP9001
+ _ => null,
+ };
+
/// Gets or sets the type of the schema.
[JsonPropertyName("type")]
public abstract string Type { get; set; }
diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
index 5d381b0..51b3d20 100644
--- a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
@@ -56,6 +56,36 @@ public sealed class ElicitResult : Result
///
[JsonPropertyName("content")]
public IDictionary? Content { get; set; }
+
+ ///
+ /// Applies default values from the elicitation request schema to any missing fields in the result content.
+ ///
+ internal static ElicitResult WithDefaults(ElicitRequestParams? requestParams, ElicitResult result)
+ {
+ if (result.IsAccepted && requestParams?.RequestedSchema?.Properties is { } properties)
+ {
+ Dictionary? newContent = null;
+
+ foreach (KeyValuePair kvp in properties)
+ {
+ if ((result.Content is null || !result.Content.ContainsKey(kvp.Key)) &&
+ kvp.Value.GetDefaultAsJsonElement() is { } element)
+ {
+ newContent ??= result.Content is not null ?
+ new Dictionary(result.Content) :
+ [];
+ newContent[kvp.Key] = element;
+ }
+ }
+
+ if (newContent is not null)
+ {
+ return new ElicitResult { Action = result.Action, Content = newContent, Meta = result.Meta };
+ }
+ }
+
+ return result;
+ }
}
///
diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
index d1df3f1..b1e73c0 100644
--- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
@@ -309,13 +309,15 @@ public abstract partial class McpServer : McpSession
Throw.IfNull(requestParams);
ThrowIfElicitationUnsupported(requestParams);
- return await SendRequestWithTaskStatusTrackingAsync(
+ var result = await SendRequestWithTaskStatusTrackingAsync(
RequestMethods.ElicitationCreate,
requestParams,
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
McpJsonUtilities.JsonContext.Default.ElicitResult,
"Waiting for user input",
cancellationToken).ConfigureAwait(false);
+
+ return ElicitResult.WithDefaults(requestParams, result);
}
///
diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs
index 9bc0acf..94ae206 100644
--- a/tests/Common/Utils/NodeHelpers.cs
+++ b/tests/Common/Utils/NodeHelpers.cs
@@ -83,10 +83,11 @@ public static class NodeHelpers
var repoRoot = FindRepoRoot();
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
+ ProcessStartInfo startInfo;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// On Windows, node_modules/.bin contains .cmd shims that can be executed directly
- return new ProcessStartInfo
+ startInfo = new ProcessStartInfo
{
FileName = $"{binPath}.cmd",
Arguments = arguments,
@@ -98,7 +99,7 @@ public static class NodeHelpers
}
else
{
- return new ProcessStartInfo
+ startInfo = new ProcessStartInfo
{
FileName = binPath,
Arguments = arguments,
@@ -108,6 +109,21 @@ public static class NodeHelpers
CreateNoWindow = true
};
}
+
+ // On macOS, disable .NET mini-dump file generation for child processes. When
+ // dotnet test runs with --blame-crash, it sets DOTNET_DbgEnableMiniDump=1 in the
+ // environment. This is inherited by grandchild .NET processes (e.g. ConformanceClient
+ // launched via node). On macOS, the createdump tool can hang indefinitely due to
+ // ptrace/SIP restrictions, causing the entire test run to hang. Disabling mini-dumps
+ // only suppresses the dump file creation; the runtime still prints crash diagnostics
+ // (stack traces, signal info, etc.) to stderr, which the test captures.
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ startInfo.Environment["DOTNET_DbgEnableMiniDump"] = "0";
+ startInfo.Environment["COMPlus_DbgEnableMiniDump"] = "0";
+ }
+
+ return startInfo;
}
///
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
index fbcbe89..295fc9b 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
@@ -24,17 +24,32 @@ public class ClientConformanceTests
[Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
[InlineData("initialize")]
[InlineData("tools_call")]
+ [InlineData("elicitation-sep1034-client-defaults")]
+ [InlineData("sse-retry")]
[InlineData("auth/metadata-default")]
[InlineData("auth/metadata-var1")]
[InlineData("auth/metadata-var2")]
[InlineData("auth/metadata-var3")]
[InlineData("auth/basic-cimd")]
- // [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
- // [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
[InlineData("auth/scope-from-www-authenticate")]
[InlineData("auth/scope-from-scopes-supported")]
[InlineData("auth/scope-omitted-when-undefined")]
[InlineData("auth/scope-step-up")]
+ [InlineData("auth/scope-retry-limit")]
+ [InlineData("auth/token-endpoint-auth-basic")]
+ [InlineData("auth/token-endpoint-auth-post")]
+ [InlineData("auth/token-endpoint-auth-none")]
+ [InlineData("auth/resource-mismatch")]
+ [InlineData("auth/pre-registration")]
+
+ // Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement.
+ // [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
+ // [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
+
+ // Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
+ // [InlineData("auth/client-credentials-jwt")]
+ // [InlineData("auth/client-credentials-basic")]
+
public async Task RunConformanceTest(string scenario)
{
// Run the conformance test suite
@@ -88,7 +103,20 @@ public class ClientConformanceTests
process.BeginOutputReadLine();
process.BeginErrorReadLine();
- await process.WaitForExitAsync();
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
+ try
+ {
+ await process.WaitForExitAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ process.Kill(entireProcessTree: true);
+ return (
+ Success: false,
+ Output: outputBuilder.ToString(),
+ Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
+ );
+ }
return (
Success: process.ExitCode == 0,
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index b0cac7c..d250145 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -5,11 +5,11 @@ using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
///
-/// Runs the official MCP conformance tests against the ConformanceServer.
-/// This test starts the ConformanceServer, runs the Node.js-based conformance test suite,
-/// and reports the results.
+/// Shared fixture that starts a single ConformanceServer instance for all tests in
+/// . This avoids TCP port TIME_WAIT conflicts
+/// that occur when each test starts and stops its own server on the same port.
///
-public class ServerConformanceTests : IAsyncLifetime
+public class ConformanceServerFixture : IAsyncLifetime
{
// Use different ports for each target framework to allow parallel execution
// net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003
@@ -27,22 +27,16 @@ public class ServerConformanceTests : IAsyncLifetime
};
}
- private readonly int _serverPort = GetPortForTargetFramework();
- private readonly string _serverUrl;
- private readonly ITestOutputHelper _output;
private Task? _serverTask;
private CancellationTokenSource? _serverCts;
- public ServerConformanceTests(ITestOutputHelper output)
- {
- _output = output;
- _serverUrl = $"http://localhost:{_serverPort}";
- }
+ public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}";
public async ValueTask InitializeAsync()
{
- // Start the ConformanceServer
- StartConformanceServer();
+ _serverCts = new CancellationTokenSource();
+ _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(
+ ["--urls", ServerUrl], cancellationToken: _serverCts.Token));
// Wait for server to be ready (retry for up to 30 seconds)
var timeout = TimeSpan.FromSeconds(30);
@@ -53,9 +47,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
try
{
- // Try to connect to the health endpoint
- await httpClient.GetAsync($"{_serverUrl}/health");
- // Any response (even an error) means the server is up
+ await httpClient.GetAsync($"{ServerUrl}/health");
return;
}
catch (HttpRequestException)
@@ -75,7 +67,6 @@ public class ServerConformanceTests : IAsyncLifetime
public async ValueTask DisposeAsync()
{
- // Stop the server
if (_serverCts != null)
{
_serverCts.Cancel();
@@ -93,30 +84,52 @@ public class ServerConformanceTests : IAsyncLifetime
_serverCts.Dispose();
}
}
+}
+///
+/// Runs the official MCP conformance tests against the ConformanceServer.
+/// Uses a shared so the server is started once
+/// and reused across all tests, avoiding TCP port conflicts on Windows.
+///
+public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output)
+ : IClassFixture
+{
[Fact]
public async Task RunConformanceTests()
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
- // Run the conformance test suite
- var result = await RunConformanceTestsAsync();
+ var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl}");
- // Report the results
Assert.True(result.Success,
$"Conformance tests failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
- private void StartConformanceServer()
+ [Fact]
+ public async Task RunPendingConformanceTest_JsonSchema202012()
{
- // Start the server in a background task
- _serverCts = new CancellationTokenSource();
- _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
+ Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
+
+ var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario json-schema-2020-12");
+
+ Assert.True(result.Success,
+ $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
- private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync()
+ [Fact]
+ public async Task RunPendingConformanceTest_ServerSsePolling()
{
- var startInfo = NodeHelpers.ConformanceTestStartInfo($"server --url {_serverUrl}");
+ Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
+
+ var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario server-sse-polling");
+
+ Assert.True(result.Success,
+ $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
+ }
+
+ private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
+ {
+ var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments);
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
@@ -127,7 +140,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
if (e.Data != null)
{
- _output.WriteLine(e.Data);
+ output.WriteLine(e.Data);
outputBuilder.AppendLine(e.Data);
}
};
@@ -136,7 +149,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
if (e.Data != null)
{
- _output.WriteLine(e.Data);
+ output.WriteLine(e.Data);
errorBuilder.AppendLine(e.Data);
}
};
@@ -145,7 +158,20 @@ public class ServerConformanceTests : IAsyncLifetime
process.BeginOutputReadLine();
process.BeginErrorReadLine();
- await process.WaitForExitAsync();
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
+ try
+ {
+ await process.WaitForExitAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ process.Kill(entireProcessTree: true);
+ return (
+ Success: false,
+ Output: outputBuilder.ToString(),
+ Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
+ );
+ }
return (
Success: process.ExitCode == 0,
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
index c6d5766..35e17be 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
@@ -470,11 +470,23 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
- // Add 5 seconds to idle timeout to account for the interval of the PeriodicTimer.
- fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
+ // The background IdleTrackingBackgroundService prunes sessions asynchronously after
+ // the PeriodicTimer (5s interval) tick fires. We advance past the 2-hour idle timeout
+ // then poll until the session returns NotFound. Each HTTP POST also refreshes the
+ // session's LastActivityTicks via AcquireReferenceAsync, so we must re-advance time
+ // each iteration to ensure the session appears idle again for the next prune pass.
+ var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout;
+ HttpStatusCode statusCode;
+ do
+ {
+ fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+ using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
+ statusCode = response.StatusCode;
+ }
+ while (statusCode != HttpStatusCode.NotFound && DateTime.UtcNow < deadline);
- using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
- Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, statusCode);
}
[Fact]
diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs
index e2f09e8..40dc424 100644
--- a/tests/ModelContextProtocol.ConformanceClient/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs
@@ -1,9 +1,10 @@
using System.Net;
using System.Net.Sockets;
-using System.Text;
+using System.Text.Json;
using System.Web;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
// This program expects the following command-line arguments:
// 1. The client conformance test scenario to run (e.g., "tools_call")
@@ -24,7 +25,15 @@ McpClientOptions options = new()
{
Name = "ConformanceClient",
Version = "1.0.0"
- }
+ },
+ Handlers = new()
+ {
+ ElicitationHandler = (request, ct) =>
+ {
+ // Accept with empty content; the SDK applies schema defaults automatically.
+ return new ValueTask(new ElicitResult { Action = "accept", Content = new Dictionary() });
+ },
+ },
};
var consoleLoggerFactory = LoggerFactory.Create(builder =>
@@ -50,21 +59,50 @@ if (callbackPort == 0)
var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback");
+// Read conformance context for scenarios that provide additional data (e.g., pre-registered credentials).
+string? preRegisteredClientId = null;
+string? preRegisteredClientSecret = null;
+var conformanceContext = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_CONTEXT");
+if (!string.IsNullOrEmpty(conformanceContext))
+{
+ using var doc = JsonDocument.Parse(conformanceContext);
+ if (doc.RootElement.TryGetProperty("client_id", out var clientIdEl))
+ {
+ preRegisteredClientId = clientIdEl.GetString();
+ }
+ if (doc.RootElement.TryGetProperty("client_secret", out var clientSecretEl))
+ {
+ preRegisteredClientSecret = clientSecretEl.GetString();
+ }
+}
+
+var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions
+{
+ RedirectUri = clientRedirectUri,
+ // Configure the metadata document URI for CIMD.
+ ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"),
+ AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct),
+};
+
+if (preRegisteredClientId is not null)
+{
+ // Use pre-registered credentials instead of DCR.
+ oauthOptions.ClientId = preRegisteredClientId;
+ oauthOptions.ClientSecret = preRegisteredClientSecret;
+}
+else
+{
+ oauthOptions.DynamicClientRegistration = new()
+ {
+ ClientName = "ProtectedMcpClient",
+ };
+}
+
var clientTransport = new HttpClientTransport(new()
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.StreamableHttp,
- OAuth = new()
- {
- RedirectUri = clientRedirectUri,
- // Configure the metadata document URI for CIMD.
- ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"),
- AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct),
- DynamicClientRegistration = new()
- {
- ClientName = "ProtectedMcpClient",
- },
- }
+ OAuth = oauthOptions,
}, loggerFactory: consoleLoggerFactory);
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
@@ -89,6 +127,26 @@ switch (scenario)
success &= !(result.IsError == true);
break;
}
+ case "elicitation-sep1034-client-defaults":
+ {
+ var tools = await mcpClient.ListToolsAsync();
+ Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
+ var toolName = "test_client_elicitation_defaults";
+ Console.WriteLine($"Calling tool: {toolName}");
+ var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary());
+ success &= !(result.IsError == true);
+ break;
+ }
+ case "sse-retry":
+ {
+ var tools = await mcpClient.ListToolsAsync();
+ Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
+ var toolName = "test_reconnection";
+ Console.WriteLine($"Calling tool: {toolName}");
+ var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary());
+ success &= !(result.IsError == true);
+ break;
+ }
case "auth/scope-step-up":
{
// Just testing that we can authenticate and list tools
@@ -96,7 +154,7 @@ switch (scenario)
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
// Call the "test_tool" tool
- var toolName = tools.FirstOrDefault()?.Name ?? "test-tool";
+ var toolName = "test-tool";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary
{
@@ -105,6 +163,20 @@ switch (scenario)
success &= !(result.IsError == true);
break;
}
+ case "auth/scope-retry-limit":
+ {
+ // Try to list tools - this triggers the auth flow that always fails with 403.
+ // The test validates the client doesn't retry indefinitely.
+ try
+ {
+ await mcpClient.ListToolsAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Expected auth failure: {ex.Message}");
+ }
+ break;
+ }
default:
// No extra processing for other scenarios
break;
diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs
index 00e557e..980fa49 100644
--- a/tests/ModelContextProtocol.ConformanceServer/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs
@@ -1,7 +1,11 @@
using ConformanceServer.Prompts;
using ConformanceServer.Resources;
using ConformanceServer.Tools;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
using System.Collections.Concurrent;
using System.Text.Json;
@@ -26,8 +30,28 @@ public class Program
builder.Services
.AddMcpServer()
- .WithHttpTransport()
+ .WithHttpTransport(options =>
+ {
+ // Enable resumability for SSE polling conformance test
+ options.EventStreamStore = new DistributedCacheEventStreamStore(
+ new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())));
+ })
.WithTools()
+ .WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
+ .AddCallToolFilter(next => async (request, cancellationToken) =>
+ {
+ var result = await next(request, cancellationToken);
+
+ // For the test_reconnection tool, enable polling mode after the tool runs.
+ // This stores the result and closes the SSE stream, so the client
+ // must reconnect via GET with Last-Event-ID to retrieve the result.
+ if (request.Params?.Name == "test_reconnection")
+ {
+ await request.EnablePollingAsync(TimeSpan.FromMilliseconds(500), cancellationToken);
+ }
+
+ return result;
+ })
.WithPrompts()
.WithResources()
.WithSubscribeToResourcesHandler(async (ctx, ct) =>
diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs
index 0328259..8f1f017 100644
--- a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs
+++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs
@@ -3,7 +3,6 @@ using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;
-using System.Text.Json.Serialization;
namespace ConformanceServer.Tools;
@@ -399,4 +398,48 @@ public class ConformanceTools
return $"Elicitation not supported or error: {ex.Message}";
}
}
+
+ /// Create the json_schema_2020_12_tool with a raw JSON Schema 2020-12 inputSchema.
+ public static McpServerTool CreateJsonSchema202012Tool()
+ {
+ var tool = McpServerTool.Create(
+ () => "JSON Schema 2020-12 tool executed successfully",
+ new()
+ {
+ Name = "json_schema_2020_12_tool",
+ Description = "Tool with JSON Schema 2020-12 features"
+ });
+
+ tool.ProtocolTool.InputSchema = JsonElement.Parse("""
+ {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "$defs": {
+ "address": {
+ "type": "object",
+ "properties": {
+ "street": { "type": "string" },
+ "city": { "type": "string" }
+ }
+ }
+ },
+ "properties": {
+ "name": { "type": "string" },
+ "address": { "$ref": "#/$defs/address" }
+ },
+ "additionalProperties": false
+ }
+ """);
+
+ return tool;
+ }
+
+ [McpServerTool(Name = "test_reconnection")]
+ [Description("Tests SSE stream reconnection by closing the stream mid-call")]
+ public static string TestReconnection()
+ {
+ // This tool doesn't need to do anything - the call filter will close the stream after this tool runs,
+ // and the client must reconnect to get the result.
+ return "Reconnection test completed successfully";
+ }
}
\ No newline at end of file
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs
index 8c3b64b..46929c0 100644
--- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs
@@ -411,7 +411,7 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- var extraMessage = await extraNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
+ var extraMessage = await extraNotificationReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.Equal("extra", extraMessage);
}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationClientDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationClientDefaultsTests.cs
new file mode 100644
index 0000000..e917905
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationClientDefaultsTests.cs
@@ -0,0 +1,420 @@
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Tests.Utils;
+using System.Text.Json;
+using System.Threading.Channels;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+///
+/// Tests verifying that the client applies elicitation schema defaults independently of the server.
+/// Uses a custom transport that acts as a server, sending elicitation requests and capturing
+/// the raw response the client sends back, before any server-side default application.
+///
+public class ElicitationClientDefaultsTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
+{
+ private static readonly ElicitRequestParams s_elicitParamsWithDefaults = new()
+ {
+ Message = "Provide info",
+ RequestedSchema = new()
+ {
+ Properties = new Dictionary
+ {
+ ["name"] = new ElicitRequestParams.StringSchema { Description = "Name", Default = "John Doe" },
+ ["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age", Default = 30 },
+ ["score"] = new ElicitRequestParams.NumberSchema { Description = "Score", Default = 95.5 },
+ ["active"] = new ElicitRequestParams.BooleanSchema { Description = "Active", Default = true },
+ ["status"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema
+ {
+ Description = "Status",
+ Enum = ["active", "inactive", "pending"],
+ Default = "active",
+ },
+ },
+ },
+ };
+
+ [Fact]
+ public async Task ClientAppliesDefaults_NullContent()
+ {
+ // Client handler returns accept with null content.
+ // The client should fill in all defaults before sending the response.
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult { Action = "accept", Content = null }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.Equal("accept", rawResult.Action);
+ Assert.NotNull(rawResult.Content);
+ Assert.Equal(5, rawResult.Content.Count);
+ Assert.Equal("John Doe", rawResult.Content["name"].GetString());
+ Assert.Equal(30, rawResult.Content["age"].GetDouble());
+ Assert.Equal(95.5, rawResult.Content["score"].GetDouble());
+ Assert.True(rawResult.Content["active"].GetBoolean());
+ Assert.Equal("active", rawResult.Content["status"].GetString());
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_EmptyContent()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult { Action = "accept", Content = new Dictionary() }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.Equal("accept", rawResult.Action);
+ Assert.NotNull(rawResult.Content);
+ Assert.Equal(5, rawResult.Content.Count);
+ Assert.Equal("John Doe", rawResult.Content["name"].GetString());
+ Assert.Equal(30, rawResult.Content["age"].GetDouble());
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_PartialContent()
+ {
+ // Client handler returns accept with only some fields.
+ // The client should fill in missing defaults before sending the response.
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult
+ {
+ Action = "accept",
+ Content = new Dictionary
+ {
+ ["name"] = JsonElement.Parse("\"Alice\""),
+ ["active"] = JsonElement.Parse("false"),
+ },
+ }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.Equal("accept", rawResult.Action);
+ Assert.NotNull(rawResult.Content);
+ Assert.Equal(5, rawResult.Content.Count);
+
+ // User-provided values preserved
+ Assert.Equal("Alice", rawResult.Content["name"].GetString());
+ Assert.False(rawResult.Content["active"].GetBoolean());
+
+ // Missing fields filled with client-side defaults
+ Assert.Equal(30, rawResult.Content["age"].GetDouble());
+ Assert.Equal(95.5, rawResult.Content["score"].GetDouble());
+ Assert.Equal("active", rawResult.Content["status"].GetString());
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_AllFieldsProvided_NoChange()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult
+ {
+ Action = "accept",
+ Content = new Dictionary
+ {
+ ["name"] = JsonElement.Parse("\"Alice\""),
+ ["age"] = JsonElement.Parse("25"),
+ ["score"] = JsonElement.Parse("88.0"),
+ ["active"] = JsonElement.Parse("false"),
+ ["status"] = JsonElement.Parse("\"inactive\""),
+ },
+ }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.NotNull(rawResult.Content);
+ Assert.Equal(5, rawResult.Content.Count);
+ Assert.Equal("Alice", rawResult.Content["name"].GetString());
+ Assert.Equal(25, rawResult.Content["age"].GetDouble());
+ Assert.Equal(88.0, rawResult.Content["score"].GetDouble());
+ Assert.False(rawResult.Content["active"].GetBoolean());
+ Assert.Equal("inactive", rawResult.Content["status"].GetString());
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_Decline_NoDefaultsApplied()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult { Action = "decline" }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.Equal("decline", rawResult.Action);
+ Assert.Null(rawResult.Content);
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_Cancel_NoDefaultsApplied()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) => new(new ElicitResult { Action = "cancel" }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.Equal("cancel", rawResult.Action);
+ Assert.Null(rawResult.Content);
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_SchemaWithNoDefaults_NoChange()
+ {
+ ElicitRequestParams paramsNoDefaults = new()
+ {
+ Message = "Provide info",
+ RequestedSchema = new()
+ {
+ Properties = new Dictionary
+ {
+ ["name"] = new ElicitRequestParams.StringSchema { Description = "Name" },
+ ["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age" },
+ },
+ },
+ };
+
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(paramsNoDefaults);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult { Action = "accept", Content = new Dictionary() }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.True(rawResult.IsAccepted);
+ Assert.NotNull(rawResult.Content);
+ Assert.Empty(rawResult.Content);
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_MultiSelectEnum()
+ {
+ ElicitRequestParams paramsMultiSelect = new()
+ {
+ Message = "Provide info",
+ RequestedSchema = new()
+ {
+ Properties = new Dictionary
+ {
+ ["tags"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema
+ {
+ Description = "Tags",
+ Items = new ElicitRequestParams.UntitledEnumItemsSchema { Enum = ["a", "b", "c"] },
+ Default = ["a", "c"],
+ },
+ ["categories"] = new ElicitRequestParams.TitledMultiSelectEnumSchema
+ {
+ Description = "Categories",
+ Items = new ElicitRequestParams.TitledEnumItemsSchema
+ {
+ AnyOf =
+ [
+ new ElicitRequestParams.EnumSchemaOption { Const = "x", Title = "X" },
+ new ElicitRequestParams.EnumSchemaOption { Const = "y", Title = "Y" },
+ ],
+ },
+ Default = ["y"],
+ },
+ },
+ },
+ };
+
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(paramsMultiSelect);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult { Action = "accept", Content = new Dictionary() }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.NotNull(rawResult.Content);
+ Assert.Equal(2, rawResult.Content.Count);
+
+ var tags = rawResult.Content["tags"].EnumerateArray().Select(e => e.GetString()!).ToArray();
+ Assert.Equal(["a", "c"], tags);
+
+ var categories = rawResult.Content["categories"].EnumerateArray().Select(e => e.GetString()!).ToArray();
+ Assert.Equal(["y"], categories);
+ }
+
+ [Fact]
+ public async Task ClientAppliesDefaults_TitledSingleSelectEnum()
+ {
+ ElicitRequestParams paramsTitledEnum = new()
+ {
+ Message = "Provide info",
+ RequestedSchema = new()
+ {
+ Properties = new Dictionary
+ {
+ ["priority"] = new ElicitRequestParams.TitledSingleSelectEnumSchema
+ {
+ Description = "Priority",
+ OneOf =
+ [
+ new ElicitRequestParams.EnumSchemaOption { Const = "low", Title = "Low" },
+ new ElicitRequestParams.EnumSchemaOption { Const = "high", Title = "High" },
+ ],
+ Default = "low",
+ },
+ },
+ },
+ };
+
+ var ct = TestContext.Current.CancellationToken;
+ await using ClientElicitationTestTransport transport = new(paramsTitledEnum);
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
+ {
+ Handlers = new()
+ {
+ ElicitationHandler = (_, _) =>
+ new(new ElicitResult { Action = "accept", Content = null }),
+ },
+ }, loggerFactory: LoggerFactory, cancellationToken: ct);
+
+ var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
+
+ Assert.NotNull(rawResult.Content);
+ Assert.Single(rawResult.Content);
+ Assert.Equal("low", rawResult.Content["priority"].GetString());
+ }
+
+ ///
+ /// Minimal transport that acts as a server, sending elicitation requests to the client
+ /// and capturing the raw response, bypassing any server-side default application.
+ ///
+ private sealed class ClientElicitationTestTransport(ElicitRequestParams elicitParams) : IClientTransport
+ {
+ private readonly Channel _incomingToClient = Channel.CreateUnbounded();
+ private readonly TaskCompletionSource _elicitResultTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _nextRequestId;
+
+ public string Name => "test-elicitation-transport";
+
+ public Task ConnectAsync(CancellationToken cancellationToken = default)
+ {
+ ITransport transport = new TransportChannel(_incomingToClient, this);
+ return Task.FromResult(transport);
+ }
+
+ public async Task SendElicitationAndGetRawResponseAsync(CancellationToken cancellationToken)
+ {
+ var requestId = new RequestId(Interlocked.Increment(ref _nextRequestId).ToString());
+ await _incomingToClient.Writer.WriteAsync(new JsonRpcRequest
+ {
+ Id = requestId,
+ Method = RequestMethods.ElicitationCreate,
+ Params = JsonSerializer.SerializeToNode(elicitParams, McpJsonUtilities.DefaultOptions),
+ }, cancellationToken);
+
+ return await _elicitResultTcs.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
+ }
+
+ private void HandleOutgoingMessage(JsonRpcMessage message)
+ {
+ if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initReq)
+ {
+ // Respond to initialize
+ _ = Task.Run(async () =>
+ {
+ await _incomingToClient.Writer.WriteAsync(new JsonRpcResponse
+ {
+ Id = initReq.Id,
+ Result = JsonSerializer.SerializeToNode(new InitializeResult
+ {
+ ProtocolVersion = "2025-03-26",
+ Capabilities = new ServerCapabilities(),
+ ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" },
+ }, McpJsonUtilities.DefaultOptions),
+ }, CancellationToken.None);
+ });
+ }
+ else if (message is JsonRpcResponse response)
+ {
+ // Capture the raw elicitation response from the client
+ if (response.Result is { } resultNode &&
+ JsonSerializer.Deserialize(resultNode, McpJsonUtilities.DefaultOptions) is { } result)
+ {
+ _elicitResultTcs.TrySetResult(result);
+ }
+ }
+ }
+
+ public ValueTask DisposeAsync() => default;
+
+ private sealed class TransportChannel(
+ Channel incoming,
+ ClientElicitationTestTransport parent) : ITransport
+ {
+ public ChannelReader 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;
+ }
+ }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationServerDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationServerDefaultsTests.cs
new file mode 100644
index 0000000..ffa806d
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationServerDefaultsTests.cs
@@ -0,0 +1,405 @@
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
+using System.Text.Json;
+using System.Threading.Channels;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+///
+/// Tests verifying that the server applies elicitation schema defaults as defense-in-depth,
+/// independent of the client. Uses a custom transport to bypass client-side default application.
+///
+public class ElicitationServerDefaultsTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
+{
+ private static ElicitRequestParams.RequestSchema s_schemaWithDefaults => new()
+ {
+ Properties = new Dictionary
+ {
+ ["name"] = new ElicitRequestParams.StringSchema { Description = "Name", Default = "John Doe" },
+ ["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age", Default = 30 },
+ ["score"] = new ElicitRequestParams.NumberSchema { Description = "Score", Default = 95.5 },
+ ["active"] = new ElicitRequestParams.BooleanSchema { Description = "Active", Default = true },
+ ["status"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema
+ {
+ Description = "Status",
+ Enum = ["active", "inactive", "pending"],
+ Default = "active",
+ },
+ },
+ };
+
+ [Fact]
+ public async Task ServerDefenseInDepth_NullContent_AppliesDefaults()
+ {
+ // Simulate a client that returns accept with null content (no defaults applied).
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult { Action = "accept", Content = null });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = s_schemaWithDefaults,
+ }, CancellationToken.None);
+
+ Assert.True(result.IsAccepted);
+ Assert.NotNull(result.Content);
+ Assert.Equal(5, result.Content.Count);
+ Assert.Equal("John Doe", result.Content["name"].GetString());
+ Assert.Equal(30, result.Content["age"].GetDouble());
+ Assert.Equal(95.5, result.Content["score"].GetDouble());
+ Assert.True(result.Content["active"].GetBoolean());
+ Assert.Equal("active", result.Content["status"].GetString());
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_EmptyContent_AppliesDefaults()
+ {
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult { Action = "accept", Content = new Dictionary() });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = s_schemaWithDefaults,
+ }, CancellationToken.None);
+
+ Assert.True(result.IsAccepted);
+ Assert.NotNull(result.Content);
+ Assert.Equal(5, result.Content.Count);
+ Assert.Equal("John Doe", result.Content["name"].GetString());
+ Assert.Equal(30, result.Content["age"].GetDouble());
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_PartialContent_FillsMissing()
+ {
+ // Simulate a client returning accept with only some fields, without applying defaults.
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult
+ {
+ Action = "accept",
+ Content = new Dictionary
+ {
+ ["name"] = JsonElement.Parse("\"Alice\""),
+ ["active"] = JsonElement.Parse("true"),
+ },
+ });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = s_schemaWithDefaults,
+ }, CancellationToken.None);
+
+ Assert.True(result.IsAccepted);
+ Assert.NotNull(result.Content);
+ Assert.Equal(5, result.Content.Count);
+
+ // User-provided values preserved
+ Assert.Equal("Alice", result.Content["name"].GetString());
+ Assert.True(result.Content["active"].GetBoolean());
+
+ // Missing fields filled with server-side defaults
+ Assert.Equal(30, result.Content["age"].GetDouble());
+ Assert.Equal(95.5, result.Content["score"].GetDouble());
+ Assert.Equal("active", result.Content["status"].GetString());
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_AllFieldsProvided_NoChange()
+ {
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult
+ {
+ Action = "accept",
+ Content = new Dictionary
+ {
+ ["name"] = JsonElement.Parse("\"Alice\""),
+ ["age"] = JsonElement.Parse("25"),
+ ["score"] = JsonElement.Parse("88.0"),
+ ["active"] = JsonElement.Parse("false"),
+ ["status"] = JsonElement.Parse("\"inactive\""),
+ },
+ });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = s_schemaWithDefaults,
+ }, CancellationToken.None);
+
+ Assert.NotNull(result.Content);
+ Assert.Equal(5, result.Content.Count);
+ Assert.Equal("Alice", result.Content["name"].GetString());
+ Assert.Equal(25, result.Content["age"].GetDouble());
+ Assert.Equal(88.0, result.Content["score"].GetDouble());
+ Assert.False(result.Content["active"].GetBoolean());
+ Assert.Equal("inactive", result.Content["status"].GetString());
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_Decline_NoDefaultsApplied()
+ {
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult { Action = "decline" });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = s_schemaWithDefaults,
+ }, CancellationToken.None);
+
+ Assert.False(result.IsAccepted);
+ Assert.Null(result.Content);
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_Cancel_NoDefaultsApplied()
+ {
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult { Action = "cancel" });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = s_schemaWithDefaults,
+ }, CancellationToken.None);
+
+ Assert.False(result.IsAccepted);
+ Assert.Null(result.Content);
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_SchemaWithNoDefaults_NoChange()
+ {
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult { Action = "accept", Content = new Dictionary() });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = new()
+ {
+ Properties = new Dictionary
+ {
+ ["name"] = new ElicitRequestParams.StringSchema { Description = "Name" },
+ ["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age" },
+ },
+ },
+ }, CancellationToken.None);
+
+ Assert.True(result.IsAccepted);
+ Assert.NotNull(result.Content);
+ Assert.Empty(result.Content);
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_MultiSelectEnum()
+ {
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult { Action = "accept", Content = new Dictionary() });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = new()
+ {
+ Properties = new Dictionary
+ {
+ ["tags"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema
+ {
+ Description = "Tags",
+ Items = new ElicitRequestParams.UntitledEnumItemsSchema { Enum = ["a", "b", "c"] },
+ Default = ["a", "c"],
+ },
+ ["categories"] = new ElicitRequestParams.TitledMultiSelectEnumSchema
+ {
+ Description = "Categories",
+ Items = new ElicitRequestParams.TitledEnumItemsSchema
+ {
+ AnyOf =
+ [
+ new ElicitRequestParams.EnumSchemaOption { Const = "x", Title = "X" },
+ new ElicitRequestParams.EnumSchemaOption { Const = "y", Title = "Y" },
+ ],
+ },
+ Default = ["y"],
+ },
+ },
+ },
+ }, CancellationToken.None);
+
+ Assert.NotNull(result.Content);
+ Assert.Equal(2, result.Content.Count);
+
+ var tags = result.Content["tags"].EnumerateArray().Select(e => e.GetString()!).ToArray();
+ Assert.Equal(["a", "c"], tags);
+
+ var categories = result.Content["categories"].EnumerateArray().Select(e => e.GetString()!).ToArray();
+ Assert.Equal(["y"], categories);
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ [Fact]
+ public async Task ServerDefenseInDepth_TitledSingleSelectEnum()
+ {
+ await using var transport = new ElicitationTestTransport(
+ new ElicitResult { Action = "accept", Content = null });
+
+ var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+ await transport.InitializeAsync();
+
+ var result = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Provide info",
+ RequestedSchema = new()
+ {
+ Properties = new Dictionary
+ {
+ ["priority"] = new ElicitRequestParams.TitledSingleSelectEnumSchema
+ {
+ Description = "Priority",
+ OneOf =
+ [
+ new ElicitRequestParams.EnumSchemaOption { Const = "low", Title = "Low" },
+ new ElicitRequestParams.EnumSchemaOption { Const = "high", Title = "High" },
+ ],
+ Default = "low",
+ },
+ },
+ },
+ }, CancellationToken.None);
+
+ Assert.NotNull(result.Content);
+ Assert.Single(result.Content);
+ Assert.Equal("low", result.Content["priority"].GetString());
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ ///
+ /// Minimal transport that responds to elicitation requests with a pre-configured result,
+ /// bypassing any client-side default application.
+ ///
+ private sealed class ElicitationTestTransport(ElicitResult elicitResult) : ITransport
+ {
+ private readonly Channel _channel = Channel.CreateUnbounded();
+ private TaskCompletionSource? _initTcs;
+
+ public ChannelReader MessageReader => _channel.Reader;
+ public bool IsConnected { get; private set; } = true;
+ public string? SessionId => null;
+
+ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
+ {
+ if (message is JsonRpcResponse response && _initTcs is { } tcs && response.Id == new RequestId("init-1"))
+ {
+ tcs.TrySetResult(true);
+ }
+ else if (message is JsonRpcRequest { Method: RequestMethods.ElicitationCreate } req)
+ {
+ // Respond with the pre-configured elicitation result via Task.Run
+ // to allow the server to finish registering the pending request.
+ _ = Task.Run(async () =>
+ {
+ await _channel.Writer.WriteAsync(new JsonRpcResponse
+ {
+ Id = req.Id,
+ Result = JsonSerializer.SerializeToNode(elicitResult, McpJsonUtilities.DefaultOptions),
+ }, cancellationToken);
+ }, cancellationToken);
+ }
+ }
+
+ public async Task InitializeAsync()
+ {
+ _initTcs = new TaskCompletionSource();
+ await _channel.Writer.WriteAsync(new JsonRpcRequest
+ {
+ Id = new RequestId("init-1"),
+ Method = RequestMethods.Initialize,
+ Params = JsonSerializer.SerializeToNode(new InitializeRequestParams
+ {
+ ProtocolVersion = "2024-11-05",
+ Capabilities = new ClientCapabilities { Elicitation = new() { Form = new() } },
+ ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
+ }, McpJsonUtilities.DefaultOptions),
+ }, CancellationToken.None);
+ await _initTcs.Task.WaitAsync(TestConstants.DefaultTimeout, CancellationToken.None);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _channel.Writer.TryComplete();
+ IsConnected = false;
+ return default;
+ }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
index ed3bf82..7367b53 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
@@ -70,7 +70,9 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
{
Name = "long-running-tool",
Arguments = EmptyArguments(),
- Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(200) }
+ // Use a TTL long enough that thread pool scheduling delays on loaded CI machines
+ // don't cause the CTS to fire before the tool lambda begins executing.
+ Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) }
},
cancellationToken: TestContext.Current.CancellationToken);
@@ -333,13 +335,16 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
RegisterMarker("short-ttl");
RegisterMarker("long-ttl");
- // Start task with short TTL (200ms)
+ // Start task with short TTL. Use a TTL long enough that thread pool scheduling
+ // delays on loaded CI machines don't cause the CTS to fire before the tool
+ // lambda begins executing (CancelAfter starts counting at task creation, not
+ // when the tool's Task.Run is scheduled).
var shortTtlResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("short-ttl"),
- Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(200) }
+ Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) }
},
cancellationToken: TestContext.Current.CancellationToken);
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
index 7f9cd61..c33ed7f 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
@@ -45,6 +45,14 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
await Assert.ThrowsAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
+ // The stderr reading thread may not have delivered the callback yet
+ // after the IOException is thrown. Poll briefly for it to arrive.
+ var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout;
+ while (Volatile.Read(ref count) == 0 && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(50, TestContext.Current.CancellationToken);
+ }
+
Assert.InRange(count, 1, int.MaxValue);
Assert.Contains(id, sb.ToString());
}
From b4c7ded398c515cbbc25fd19b50ed7cf5e221720 Mon Sep 17 00:00:00 2001
From: Stephen Halter
Date: Thu, 12 Feb 2026 19:26:30 -0800
Subject: [PATCH 033/320] Add tests to verify McpClient.DisposeAsync doesn't
hang (#1252)
---
.../MapMcpStreamableHttpTests.cs | 135 ++++++++++++++++++
.../StreamableHttpClientConformanceTests.cs | 89 ++++++++++++
2 files changed, 224 insertions(+)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
index 2098e79..aa619d9 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
@@ -412,4 +412,139 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Assert.True(wasPostRequest, "POST request was not made");
Assert.True(wasDeleteRequest, "DELETE request was not made");
}
+
+ [Fact]
+ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse()
+ {
+ Assert.SkipWhen(Stateless, "Stateless mode doesn't support session management.");
+
+ var getResponseStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools();
+
+ await using var app = Builder.Build();
+
+ // Track when the GET SSE response starts being written, which indicates
+ // the server's HandleGetRequestAsync has fully initialized the SSE writer.
+ app.Use(next =>
+ {
+ return async context =>
+ {
+ if (context.Request.Method == HttpMethods.Get)
+ {
+ context.Response.OnStarting(() =>
+ {
+ getResponseStarted.TrySetResult();
+ return Task.CompletedTask;
+ });
+ }
+ await next(context);
+ };
+ });
+
+ app.MapMcp();
+ await app.StartAsync(TestContext.Current.CancellationToken);
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new("http://localhost:5000/"),
+ TransportMode = HttpTransportMode.StreamableHttp,
+ OwnsSession = false,
+ }, HttpClient, LoggerFactory);
+
+ var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+
+ // Call a tool to ensure the session is fully established
+ var result = await client.CallToolAsync(
+ "echo_claims_principal",
+ new Dictionary() { ["message"] = "Hello!" },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result);
+
+ // Wait for the GET SSE stream to be fully established on the server
+ await getResponseStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+
+ // This should not hang. The issue reports that DisposeAsync hangs indefinitely
+ // when OwnsSession is false. Use a timeout to detect the hang.
+ await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithUnsolicitedMessages()
+ {
+ Assert.SkipWhen(Stateless, "Stateless mode doesn't support session management.");
+
+ var getResponseStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var serverTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Builder.Services.AddMcpServer().WithHttpTransport(opts =>
+ {
+ ConfigureStateless(opts);
+ opts.RunSessionHandler = async (context, server, cancellationToken) =>
+ {
+ serverTcs.TrySetResult(server);
+ await server.RunAsync(cancellationToken);
+ };
+ }).WithTools();
+
+ await using var app = Builder.Build();
+
+ // Track when the GET SSE response starts being written, which indicates
+ // the server's HandleGetRequestAsync has fully initialized the SSE writer.
+ app.Use(next =>
+ {
+ return async context =>
+ {
+ if (context.Request.Method == HttpMethods.Get)
+ {
+ context.Response.OnStarting(() =>
+ {
+ getResponseStarted.TrySetResult();
+ return Task.CompletedTask;
+ });
+ }
+ await next(context);
+ };
+ });
+
+ app.MapMcp();
+ await app.StartAsync(TestContext.Current.CancellationToken);
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new("http://localhost:5000/"),
+ TransportMode = HttpTransportMode.StreamableHttp,
+ OwnsSession = false,
+ }, HttpClient, LoggerFactory);
+
+ var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+
+ var result = await client.CallToolAsync(
+ "echo_claims_principal",
+ new Dictionary() { ["message"] = "Hello!" },
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.NotNull(result);
+
+ // Wait for the GET SSE stream to be fully established on the server
+ await getResponseStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+
+ // Register a handler on the client to detect when the notification is received
+ var notificationReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var handlerRegistration = client.RegisterNotificationHandler("notifications/tools/list_changed", (notification, ct) =>
+ {
+ notificationReceived.TrySetResult();
+ return default;
+ });
+
+ // Get the server instance and send an unsolicited notification by modifying tools
+ var server = await serverTcs.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ await server.SendNotificationAsync("notifications/tools/list_changed", TestContext.Current.CancellationToken);
+
+ // Wait for the client to actually receive the notification
+ await notificationReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+
+ // Dispose should still not hang
+ await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ }
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs
index 4cf7b71..296af1c 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs
@@ -281,6 +281,95 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
Assert.Contains(nameof(McpClient.ResumeSessionAsync), exception.Message);
}
+ [Fact]
+ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithActiveGetStream()
+ {
+ var getRequestReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Builder.Services.Configure(options =>
+ {
+ options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
+ });
+ _app = Builder.Build();
+
+ var echoTool = McpServerTool.Create(Echo, new() { Services = _app.Services });
+
+ _app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
+ {
+ if (message is not JsonRpcRequest request)
+ {
+ return Results.Accepted();
+ }
+
+ context.Response.Headers.Append("mcp-session-id", "hang-test-session");
+
+ if (request.Method == "initialize")
+ {
+ return Results.Json(new JsonRpcResponse
+ {
+ Id = request.Id,
+ Result = JsonSerializer.SerializeToNode(new InitializeResult
+ {
+ ProtocolVersion = "2024-11-05",
+ Capabilities = new() { Tools = new() },
+ ServerInfo = new Implementation { Name = "hang-test", Version = "0.0.1" },
+ }, McpJsonUtilities.DefaultOptions)
+ });
+ }
+
+ if (request.Method == "tools/list")
+ {
+ return Results.Json(new JsonRpcResponse
+ {
+ Id = request.Id,
+ Result = JsonSerializer.SerializeToNode(new ListToolsResult
+ {
+ Tools = [echoTool.ProtocolTool]
+ }, McpJsonUtilities.DefaultOptions),
+ });
+ }
+
+ return Results.Accepted();
+ });
+
+ // GET handler that keeps the SSE stream open indefinitely (like a real MCP server)
+ _app.MapGet("/mcp", async context =>
+ {
+ context.Response.Headers.ContentType = "text/event-stream";
+ getRequestReceived.TrySetResult();
+ await context.Response.Body.FlushAsync(TestContext.Current.CancellationToken);
+
+ try
+ {
+ await Task.Delay(Timeout.Infinite, context.RequestAborted);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ });
+
+ await _app.StartAsync(TestContext.Current.CancellationToken);
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new("http://localhost:5000/mcp"),
+ TransportMode = HttpTransportMode.StreamableHttp,
+ OwnsSession = false,
+ }, HttpClient, LoggerFactory);
+
+ await using (var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
+ {
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Single(tools);
+
+ // Wait for the GET SSE stream to be established on the server
+ await getRequestReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+
+ // Dispose should not hang even though the GET stream is actively open
+ await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ }
+ }
+
private static async Task CallEchoAndValidateAsync(McpClientTool echoTool)
{
var response = await echoTool.CallAsync(new Dictionary() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);
From dbf91bcb1a515109383e64e6022fecf094ea1aea Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 22:28:43 -0500
Subject: [PATCH 034/320] Change ProtectedResourceMetadata URI properties to
strings and build resource strings directly (#1264)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
Co-authored-by: Stephen Halter
---
samples/ProtectedMcpServer/Program.cs | 4 +-
.../McpAuthenticationHandler.cs | 32 +++--
.../Authentication/ClientOAuthProvider.cs | 48 +++++--
.../ProtectedResourceMetadata.cs | 24 ++--
.../OAuth/AuthEventTests.cs | 16 +--
.../OAuth/AuthTests.cs | 121 ++++++++++++++++--
.../OAuth/McpAuthenticationHandlerTests.cs | 10 +-
.../OAuth/OAuthTestBase.cs | 2 +-
.../Program.cs | 7 +-
9 files changed, 209 insertions(+), 55 deletions(-)
diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs
index 83b4f6d..97f8456 100644
--- a/samples/ProtectedMcpServer/Program.cs
+++ b/samples/ProtectedMcpServer/Program.cs
@@ -56,8 +56,8 @@ builder.Services.AddAuthentication(options =>
{
options.ResourceMetadata = new()
{
- ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
- AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
+ ResourceDocumentation = "https://docs.example.com/api/weather",
+ AuthorizationServers = { inMemoryOAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
});
diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs
index 60adfb2..b30c7d5 100644
--- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs
@@ -56,17 +56,29 @@ public partial class McpAuthenticationHandler : AuthenticationHandler
@@ -128,9 +140,9 @@ public partial class McpAuthenticationHandler : AuthenticationHandler HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
+ private async Task HandleResourceMetadataRequestAsync(string? derivedResource = null)
{
- var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResourceUri);
+ var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResource);
if (Options.Events.OnResourceMetadataRequest is not null)
{
@@ -165,7 +177,7 @@ public partial class McpAuthenticationHandler : AuthenticationHandler 0 } refreshToken)
{
- var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
+ var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
return (accessToken, true);
}
@@ -243,15 +243,26 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
}
+ // Convert string URIs to Uri objects for the selector
+ List authServerUris = [];
+ foreach (var serverUriString in availableAuthorizationServers)
+ {
+ if (!Uri.TryCreate(serverUriString, UriKind.Absolute, out var serverUri))
+ {
+ ThrowFailedToHandleUnauthorizedResponse($"Invalid authorization server URI: '{serverUriString}'. Available servers: {string.Join(", ", availableAuthorizationServers)}");
+ }
+ authServerUris.Add(serverUri);
+ }
+
// Select authorization server using configured strategy
- var selectedAuthServer = _authServerSelector(availableAuthorizationServers);
+ var selectedAuthServer = _authServerSelector(authServerUris);
if (selectedAuthServer is null)
{
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selection returned null. Available servers: {string.Join(", ", availableAuthorizationServers)}");
}
- if (!availableAuthorizationServers.Contains(selectedAuthServer))
+ if (!authServerUris.Contains(selectedAuthServer))
{
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selector returned a server not in the available list: {selectedAuthServer}. Available servers: {string.Join(", ", availableAuthorizationServers)}");
}
@@ -387,13 +398,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
}
}
- private async Task RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
+ private async Task RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
{
Dictionary formFields = new()
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
- ["resource"] = resourceUri.ToString(),
+ ["resource"] = resourceUri,
};
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
@@ -443,7 +454,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
["response_type"] = "code",
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256",
- ["resource"] = resourceUri.ToString(),
+ ["resource"] = resourceUri,
};
var scope = GetScopeParameter(protectedResourceMetadata);
@@ -487,7 +498,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
["code"] = authorizationCode,
["redirect_uri"] = _redirectUri.ToString(),
["code_verifier"] = codeVerifier,
- ["resource"] = resourceUri.ToString(),
+ ["resource"] = resourceUri,
};
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
@@ -659,7 +670,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
}
}
- private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
+ private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
{
if (protectedResourceMetadata.Resource is null)
{
@@ -732,6 +743,27 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
return builder.ToString();
}
+ ///
+ /// Normalizes a URI string for consistent comparison.
+ ///
+ /// The URI string to normalize.
+ ///
+ /// A normalized string representation of the URI. If the string is a valid absolute URI,
+ /// it is parsed and normalized (scheme, host, port, and path without trailing slash).
+ /// If the string is not a valid absolute URI, only the trailing slash is removed.
+ ///
+ private static string NormalizeUri(string uriString)
+ {
+ // Parse the string as a URI to normalize it
+ if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
+ {
+ // If it's not a valid URI, return the string with trailing slash removed
+ return uriString.TrimEnd('/');
+ }
+
+ return NormalizeUri(uri);
+ }
+
///
/// Responds to a 401 challenge by parsing the WWW-Authenticate header, fetching the resource metadata,
/// verifying the resource match, and returning the metadata if valid.
diff --git a/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs b/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs
index 6bdb5de..2e3027d 100644
--- a/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Authentication;
@@ -20,7 +21,8 @@ public sealed class ProtectedResourceMetadata
/// Resource must be explicitly set. Automatic inference only works with the default endpoint pattern.
///
[JsonPropertyName("resource")]
- public Uri? Resource { get; set; }
+ [StringSyntax(StringSyntaxAttribute.Uri)]
+ public string? Resource { get; set; }
///
/// Gets or sets the list of authorization server URIs.
@@ -33,7 +35,7 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL.
///
[JsonPropertyName("authorization_servers")]
- public List AuthorizationServers { get; set; } = [];
+ public List AuthorizationServers { get; set; } = [];
///
/// Gets or sets the supported bearer token methods.
@@ -69,7 +71,8 @@ public sealed class ProtectedResourceMetadata
/// that the resource server uses to sign resource responses. This URL MUST use the HTTPS scheme.
///
[JsonPropertyName("jwks_uri")]
- public Uri? JwksUri { get; set; }
+ [StringSyntax(StringSyntaxAttribute.Uri)]
+ public string? JwksUri { get; set; }
///
/// Gets or sets the list of the JWS signing algorithms supported by the protected resource for signing resource responses.
@@ -105,7 +108,8 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL.
///
[JsonPropertyName("resource_documentation")]
- public Uri? ResourceDocumentation { get; set; }
+ [StringSyntax(StringSyntaxAttribute.Uri)]
+ public string? ResourceDocumentation { get; set; }
///
/// Gets or sets the URL of a page containing human-readable information about the protected resource's requirements.
@@ -117,7 +121,8 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL.
///
[JsonPropertyName("resource_policy_uri")]
- public Uri? ResourcePolicyUri { get; set; }
+ [StringSyntax(StringSyntaxAttribute.Uri)]
+ public string? ResourcePolicyUri { get; set; }
///
/// Gets or sets the URL of a page containing human-readable information about the protected resource's terms of service.
@@ -126,7 +131,8 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL. The value of this field MAY be internationalized.
///
[JsonPropertyName("resource_tos_uri")]
- public Uri? ResourceTosUri { get; set; }
+ [StringSyntax(StringSyntaxAttribute.Uri)]
+ public string? ResourceTosUri { get; set; }
///
/// Gets or sets a value indicating whether there is protected resource support for mutual-TLS client certificate-bound access tokens.
@@ -195,13 +201,13 @@ public sealed class ProtectedResourceMetadata
///
/// Creates a deep copy of this instance, optionally overriding the Resource property.
///
- /// Optional URI to use for the Resource property if the original Resource is null.
+ /// Optional resource URI string to use for the Resource property if the original Resource is null.
/// A new instance of with cloned values.
- public ProtectedResourceMetadata Clone(Uri? derivedResourceUri = null)
+ public ProtectedResourceMetadata Clone(string? derivedResource = null)
{
return new ProtectedResourceMetadata
{
- Resource = Resource ?? derivedResourceUri,
+ Resource = Resource ?? derivedResource,
AuthorizationServers = [.. AuthorizationServers],
BearerMethodsSupported = [.. BearerMethodsSupported],
ScopesSupported = [.. ScopesSupported],
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs
index 001aa82..7aafd31 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs
@@ -25,8 +25,8 @@ public class AuthEventTests : OAuthTestBase
// Dynamically provide the resource metadata
context.ResourceMetadata = new ProtectedResourceMetadata
{
- Resource = new Uri(McpServerUrl),
- AuthorizationServers = { new Uri(OAuthServerUrl) },
+ Resource = McpServerUrl,
+ AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
await Task.CompletedTask;
@@ -124,8 +124,8 @@ public class AuthEventTests : OAuthTestBase
);
Assert.NotNull(metadata);
- Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
- Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
+ Assert.Equal(McpServerUrl, metadata.Resource);
+ Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
Assert.Contains("mcp:tools", metadata.ScopesSupported);
}
@@ -140,8 +140,8 @@ public class AuthEventTests : OAuthTestBase
// Set initial metadata
options.ResourceMetadata = new ProtectedResourceMetadata
{
- Resource = new Uri(McpServerUrl),
- AuthorizationServers = { new Uri(OAuthServerUrl) },
+ Resource = McpServerUrl,
+ AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:basic"],
};
@@ -175,8 +175,8 @@ public class AuthEventTests : OAuthTestBase
);
Assert.NotNull(metadata);
- Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
- Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
+ Assert.Equal(McpServerUrl, metadata.Resource);
+ Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
Assert.Contains("mcp:basic", metadata.ScopesSupported);
Assert.Contains("mcp:tools", metadata.ScopesSupported);
Assert.Equal("Dynamic Test Resource", metadata.ResourceName);
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index be831d5..d400783 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -9,6 +9,7 @@ using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
using System.Net;
+using System.Net.Http.Json;
using System.Security.Claims;
using Xunit.Sdk;
@@ -506,7 +507,7 @@ public class AuthTests : OAuthTestBase
{
Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
- options.ResourceMetadata!.Resource = new Uri("http://localhost:5999");
+ options.ResourceMetadata!.Resource = "http://localhost:5999";
});
await using var app = await StartMcpServerAsync();
@@ -532,7 +533,7 @@ public class AuthTests : OAuthTestBase
{
Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
- options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}/tenant1")];
+ options.ResourceMetadata!.AuthorizationServers = [$"{OAuthServerUrl}/tenant1"];
});
await using var app = await StartMcpServerAsync();
@@ -565,7 +566,7 @@ public class AuthTests : OAuthTestBase
Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
- options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}{issuerPath}")];
+ options.ResourceMetadata!.AuthorizationServers = [$"{OAuthServerUrl}{issuerPath}"];
});
await using var app = await StartMcpServerAsync();
@@ -606,8 +607,8 @@ public class AuthTests : OAuthTestBase
var metadata = new ProtectedResourceMetadata
{
- Resource = new Uri($"{McpServerUrl}{resourcePath}"),
- AuthorizationServers = { new Uri(OAuthServerUrl) },
+ Resource = $"{McpServerUrl}{resourcePath}",
+ AuthorizationServers = { OAuthServerUrl },
};
app.Use(async (context, next) =>
@@ -678,8 +679,8 @@ public class AuthTests : OAuthTestBase
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
- Resource = new Uri($"{McpServerUrl}{configuredResourcePath}"),
- AuthorizationServers = { new Uri(OAuthServerUrl) },
+ Resource = $"{McpServerUrl}{configuredResourcePath}",
+ AuthorizationServers = { OAuthServerUrl },
};
});
@@ -719,8 +720,8 @@ public class AuthTests : OAuthTestBase
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
- Resource = new Uri($"{McpServerUrl}"),
- AuthorizationServers = { new Uri(OAuthServerUrl) },
+ Resource = McpServerUrl,
+ AuthorizationServers = { OAuthServerUrl },
};
});
@@ -750,4 +751,106 @@ public class AuthTests : OAuthTestBase
Assert.Contains("does not match", ex.Message);
}
+
+ [Fact]
+ public async Task ResourceMetadata_DoesNotAddTrailingSlash()
+ {
+ // This test verifies that automatically derived resource URIs don't have trailing slashes
+ // and that the client doesn't add them during authentication
+
+ // Don't explicitly set Resource - let it be derived from the request
+ await using var app = await StartMcpServerAsync();
+
+ // First, manually check the PRM document doesn't contain a trailing slash
+ using var metadataResponse = await HttpClient.GetAsync(
+ "/.well-known/oauth-protected-resource",
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode);
+
+ var metadata = await metadataResponse.Content.ReadFromJsonAsync(
+ McpJsonUtilities.DefaultOptions,
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.NotNull(metadata);
+ Assert.Equal("http://localhost:5000", metadata.Resource);
+ Assert.DoesNotMatch(@"/$", metadata.Resource); // No trailing slash
+
+ // Then authenticate with the client - this will use the derived resource URI
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ },
+ }, HttpClient, LoggerFactory);
+
+ // This should succeed - the client should not add a trailing slash
+ // If the client incorrectly added a trailing slash, ValidResources would reject it
+ await using var client = await McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
+ {
+ // This test verifies that explicitly configured trailing slashes are preserved
+ const string resourceWithTrailingSlash = "http://localhost:5000/";
+
+ // Configure ValidResources to accept the trailing slash version for this test
+ TestOAuthServer.ValidResources = [resourceWithTrailingSlash, "http://localhost:5000/mcp"];
+
+ Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ options.ResourceMetadata = new ProtectedResourceMetadata
+ {
+ Resource = resourceWithTrailingSlash,
+ AuthorizationServers = { OAuthServerUrl },
+ ScopesSupported = ["mcp:tools"],
+ };
+ });
+
+ await using var app = await StartMcpServerAsync();
+
+ // First, manually check the PRM document contains the trailing slash
+ using var metadataResponse = await HttpClient.GetAsync(
+ "/.well-known/oauth-protected-resource",
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode);
+
+ var metadata = await metadataResponse.Content.ReadFromJsonAsync(
+ McpJsonUtilities.DefaultOptions,
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.NotNull(metadata);
+ Assert.Equal(resourceWithTrailingSlash, metadata.Resource);
+ Assert.Matches(@"/$", metadata.Resource); // Has trailing slash
+
+ // Then authenticate with the client
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ },
+ }, HttpClient, LoggerFactory);
+
+ // This should succeed with the explicitly configured trailing slash
+ // If the client incorrectly trimmed the slash, ValidResources would reject it
+ await using var client = await McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+ }
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/McpAuthenticationHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/McpAuthenticationHandlerTests.cs
index 01cb845..c4659e5 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/McpAuthenticationHandlerTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/McpAuthenticationHandlerTests.cs
@@ -23,7 +23,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
- options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
+ options.ResourceMetadata!.Resource = "http://localhost:5000/challenge";
});
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
@@ -64,7 +64,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = metadataUri;
- options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
+ options.ResourceMetadata!.Resource = "http://localhost:5000/challenge";
});
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
@@ -137,7 +137,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
- Assert.Equal(new Uri("http://localhost:5000/resource/tools"), metadata!.Resource);
+ Assert.Equal("http://localhost:5000/resource/tools", metadata!.Resource);
}
[Fact]
@@ -153,7 +153,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
- Assert.Equal(new Uri("http://localhost:5000/api/resource/tools"), metadata!.Resource);
+ Assert.Equal("http://localhost:5000/api/resource/tools", metadata!.Resource);
}
private async Task StartAuthenticationServerAsync(Action? configureOptions = null, PathString? pathBase = null)
@@ -169,7 +169,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
{
options.ResourceMetadata = new()
{
- AuthorizationServers = [new Uri("https://localhost:7029")],
+ AuthorizationServers = ["https://localhost:7029"],
ScopesSupported = ["mcp:tools"],
};
configureOptions?.Invoke(options);
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
index 5d66ee8..f744b77 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
@@ -55,7 +55,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
- AuthorizationServers = { new Uri(OAuthServerUrl) },
+ AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:tools"]
};
}
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index 3648363..d8923b7 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -16,10 +16,11 @@ public sealed class Program
private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json";
// Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample
- private static readonly string[] ValidResources = [
- "http://localhost:5000/",
+ // Per MCP spec, URIs should not have trailing slashes unless semantically significant
+ public string[] ValidResources { get; set; } = [
+ "http://localhost:5000",
"http://localhost:5000/mcp",
- "http://localhost:7071/"
+ "http://localhost:7071"
];
private readonly ConcurrentDictionary _authCodes = new();
From 53e9ed8f656d610bd6aa9be89fb9c8a514c99a4d Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Feb 2026 09:24:02 -0800
Subject: [PATCH 035/320] Log tool call name on success, not just failure
(#1256)
---
.../Server/McpServerImpl.cs | 69 ++++++++--
.../McpServerBuilderExtensionsPromptsTests.cs | 86 +++++++++++-
...cpServerBuilderExtensionsResourcesTests.cs | 82 +++++++++++-
.../McpServerBuilderExtensionsToolsTests.cs | 105 ++++++++++++++-
.../Server/ToolTaskSupportTests.cs | 123 ++++++++++++++++++
5 files changed, 443 insertions(+), 22 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index e58857d..04f3294 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -392,7 +392,17 @@ internal sealed partial class McpServerImpl : McpServer
}
}
- return await handler(request, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var result = await handler(request, cancellationToken).ConfigureAwait(false);
+ ReadResourceCompleted(request.Params?.Uri ?? string.Empty);
+ return result;
+ }
+ catch (Exception e)
+ {
+ ReadResourceError(request.Params?.Uri ?? string.Empty, e);
+ throw;
+ }
});
subscribeHandler = BuildFilterPipeline(subscribeHandler, options.Filters.SubscribeToResourcesFilters);
unsubscribeHandler = BuildFilterPipeline(unsubscribeHandler, options.Filters.UnsubscribeFromResourcesFilters);
@@ -487,7 +497,7 @@ internal sealed partial class McpServerImpl : McpServer
listPromptsHandler = BuildFilterPipeline(listPromptsHandler, options.Filters.ListPromptsFilters);
getPromptHandler = BuildFilterPipeline(getPromptHandler, options.Filters.GetPromptFilters, handler =>
- (request, cancellationToken) =>
+ async (request, cancellationToken) =>
{
// Initial handler that sets MatchedPrimitive
if (request.Params?.Name is { } promptName && prompts is not null &&
@@ -496,7 +506,17 @@ internal sealed partial class McpServerImpl : McpServer
request.MatchedPrimitive = prompt;
}
- return handler(request, cancellationToken);
+ try
+ {
+ var result = await handler(request, cancellationToken).ConfigureAwait(false);
+ GetPromptCompleted(request.Params?.Name ?? string.Empty);
+ return result;
+ }
+ catch (Exception e)
+ {
+ GetPromptError(request.Params?.Name ?? string.Empty, e);
+ throw;
+ }
});
ServerCapabilities.Prompts.ListChanged = listChanged;
@@ -610,20 +630,35 @@ internal sealed partial class McpServerImpl : McpServer
try
{
- return await handler(request, cancellationToken).ConfigureAwait(false);
+ var result = await handler(request, cancellationToken).ConfigureAwait(false);
+
+ // Don't log here for task-augmented calls; logging happens asynchronously
+ // in ExecuteToolAsTaskAsync when the tool actually completes.
+ if (result.Task is null)
+ {
+ ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
+ }
+
+ return result;
}
- catch (Exception e) when (e is not OperationCanceledException and not McpProtocolException)
+ catch (Exception e)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
- string errorMessage = e is McpException ?
- $"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
- $"An error occurred invoking '{request.Params?.Name}'.";
+ if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException)
+ {
+ throw;
+ }
return new()
{
IsError = true,
- Content = [new TextContentBlock { Text = errorMessage }],
+ Content = [new TextContentBlock
+ {
+ Text = e is McpException ?
+ $"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
+ $"An error occurred invoking '{request.Params?.Name}'.",
+ }],
};
}
});
@@ -944,6 +979,21 @@ internal sealed partial class McpServerImpl : McpServer
[LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")]
private partial void ToolCallError(string toolName, Exception exception);
+ [LoggerMessage(Level = LogLevel.Information, Message = "\"{ToolName}\" completed. IsError = {IsError}.")]
+ private partial void ToolCallCompleted(string toolName, bool isError);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "GetPrompt \"{PromptName}\" threw an unhandled exception.")]
+ private partial void GetPromptError(string promptName, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "GetPrompt \"{PromptName}\" completed.")]
+ private partial void GetPromptCompleted(string promptName);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "ReadResource \"{ResourceUri}\" threw an unhandled exception.")]
+ private partial void ReadResourceError(string resourceUri, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")]
+ private partial void ReadResourceCompleted(string resourceUri);
+
///
/// Executes a tool call as a task and returns a CallToolTaskResult immediately.
///
@@ -1004,6 +1054,7 @@ internal sealed partial class McpServerImpl : McpServer
// Invoke the tool with task-specific cancellation token
var result = await tool.InvokeAsync(request, taskCancellationToken).ConfigureAwait(false);
+ ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
// Determine final status based on whether there was an error
var finalStatus = result.IsError is true ? McpTaskStatus.Failed : McpTaskStatus.Completed;
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs
index 3b9137f..69405e1 100644
--- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -101,7 +102,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
await using McpClient client = await CreateMcpClientForServer();
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, prompts.Count);
+ Assert.Equal(8, prompts.Count);
var prompt = prompts.First(t => t.Name == "returns_chat_messages");
Assert.Equal("Returns chat messages", prompt.Description);
@@ -130,7 +131,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
await using McpClient client = await CreateMcpClientForServer();
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, prompts.Count);
+ Assert.Equal(8, prompts.Count);
Channel listChanged = Channel.CreateUnbounded();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -151,7 +152,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
await notificationRead;
prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(7, prompts.Count);
+ Assert.Equal(9, prompts.Count);
Assert.Contains(prompts, t => t.Name == "NewPrompt");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -161,7 +162,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
}
prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, prompts.Count);
+ Assert.Equal(8, prompts.Count);
Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt");
}
@@ -195,6 +196,75 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
cancellationToken: TestContext.Current.CancellationToken));
}
+ [Fact]
+ public async Task Logs_Prompt_Name_On_Successful_Call()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.GetPromptAsync(
+ "returns_chat_messages",
+ new Dictionary { ["message"] = "hello" },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "GetPrompt \"returns_chat_messages\" completed.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Prompt_Name_When_Prompt_Throws()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.GetPromptAsync(
+ "throws_exception",
+ new Dictionary { ["message"] = "test" },
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("GetPrompt \"throws_exception\" threw an unhandled exception.", errorLog.Message);
+ Assert.IsType(errorLog.Exception);
+ }
+
+ [Fact]
+ public async Task Logs_Prompt_Error_When_Prompt_Throws_OperationCanceledException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.GetPromptAsync(
+ "throws_operation_canceled_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "GetPrompt \"throws_operation_canceled_exception\" threw an unhandled exception." &&
+ m.Exception is OperationCanceledException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
+ [Fact]
+ public async Task Logs_Prompt_Error_When_Prompt_Throws_McpProtocolException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.GetPromptAsync(
+ "throws_mcp_protocol_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "GetPrompt \"throws_mcp_protocol_exception\" threw an unhandled exception." &&
+ m.Exception is McpProtocolException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
[Fact]
public async Task Throws_Exception_On_Unknown_Prompt()
{
@@ -335,6 +405,14 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
public static ChatMessage[] ThrowsException([Description("The first parameter")] string message) =>
throw new FormatException("uh oh");
+ [McpServerPrompt, Description("Throws OperationCanceledException")]
+ public static ChatMessage[] ThrowsOperationCanceledException() =>
+ throw new OperationCanceledException("Prompt was canceled");
+
+ [McpServerPrompt, Description("Throws McpProtocolException")]
+ public static ChatMessage[] ThrowsMcpProtocolException() =>
+ throw new McpProtocolException("Prompt protocol error", McpErrorCode.InvalidParams);
+
[McpServerPrompt(Title = "This is a title", IconSource = "https://example.com/prompt-icon.svg"), Description("Returns chat messages")]
public string ReturnsString([Description("The first parameter")] string message) =>
$"The prompt is: {message}. The id is {id}.";
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
index 5d3f562..545384a 100644
--- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -130,7 +131,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
Assert.NotNull(client.ServerCapabilities.Resources);
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(5, resources.Count);
+ Assert.Equal(7, resources.Count);
var resource = resources.First(t => t.Name == "some_neat_direct_resource");
Assert.Equal("Some neat direct resource", resource.Description);
@@ -164,7 +165,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
await using McpClient client = await CreateMcpClientForServer();
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(5, resources.Count);
+ Assert.Equal(7, resources.Count);
Channel listChanged = Channel.CreateUnbounded();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -185,7 +186,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
await notificationRead;
resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, resources.Count);
+ Assert.Equal(8, resources.Count);
Assert.Contains(resources, t => t.Name == "NewResource");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -195,7 +196,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
}
resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(5, resources.Count);
+ Assert.Equal(7, resources.Count);
Assert.DoesNotContain(resources, t => t.Name == "NewResource");
}
@@ -239,6 +240,73 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
cancellationToken: TestContext.Current.CancellationToken));
}
+ [Fact]
+ public async Task Logs_Resource_Uri_On_Successful_Read()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ReadResourceAsync(
+ "resource://mcp/some_neat_direct_resource",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "ReadResource \"resource://mcp/some_neat_direct_resource\" completed.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Resource_Uri_When_Resource_Throws()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ "resource://mcp/throws_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("ReadResource \"resource://mcp/throws_exception\" threw an unhandled exception.", errorLog.Message);
+ Assert.IsType(errorLog.Exception);
+ }
+
+ [Fact]
+ public async Task Logs_Resource_Error_When_Resource_Throws_OperationCanceledException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ "resource://mcp/throws_operation_canceled_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "ReadResource \"resource://mcp/throws_operation_canceled_exception\" threw an unhandled exception." &&
+ m.Exception is OperationCanceledException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
+ [Fact]
+ public async Task Logs_Resource_Error_When_Resource_Throws_McpProtocolException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ "resource://mcp/throws_mcp_protocol_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "ReadResource \"resource://mcp/throws_mcp_protocol_exception\" threw an unhandled exception." &&
+ m.Exception is McpProtocolException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
[Fact]
public async Task Throws_Exception_On_Unknown_Resource()
{
@@ -361,6 +429,12 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
[McpServerResource]
public static string ThrowsException() => throw new InvalidOperationException("uh oh");
+
+ [McpServerResource]
+ public static string ThrowsOperationCanceledException() => throw new OperationCanceledException("Resource was canceled");
+
+ [McpServerResource]
+ public static string ThrowsMcpProtocolException() => throw new McpProtocolException("Resource protocol error", McpErrorCode.InvalidParams);
}
[McpServerResourceType]
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs
index a0fb3cb..518b70f 100644
--- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs
@@ -127,7 +127,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
await using McpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
McpClientTool echoTool = tools.First(t => t.Name == "echo");
Assert.Equal("Echoes the input back to the client.", echoTool.Description);
@@ -165,7 +165,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
cancellationToken: TestContext.Current.CancellationToken))
{
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
McpClientTool echoTool = tools.First(t => t.Name == "echo");
Assert.Equal("Echoes the input back to the client.", echoTool.Description);
@@ -191,7 +191,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
await using McpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
Channel listChanged = Channel.CreateUnbounded();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -212,7 +212,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
await notificationRead;
tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(17, tools.Count);
+ Assert.Equal(20, tools.Count);
Assert.Contains(tools, t => t.Name == "NewTool");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -222,7 +222,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
}
tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
}
@@ -380,6 +380,78 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
Assert.Equal("Test error", errorLog.Exception.Message);
}
+ [Fact]
+ public async Task Logs_Tool_Name_On_Successful_Call()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.CallToolAsync(
+ "echo",
+ new Dictionary { ["message"] = "test" },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsError is not true);
+ Assert.Equal("hello test", (result.Content[0] as TextContentBlock)?.Text);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"echo\" completed. IsError = False.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Tool_Name_With_IsError_When_Tool_Returns_Error()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.CallToolAsync(
+ "return_is_error",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsError);
+ Assert.Contains("Tool returned an error", (result.Content[0] as TextContentBlock)?.Text);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"return_is_error\" completed. IsError = True.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Tool_Error_When_Tool_Throws_OperationCanceledException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.CallToolAsync(
+ "throw_operation_canceled_exception",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsError);
+ Assert.NotNull(result.Content);
+ Assert.NotEmpty(result.Content);
+ Assert.Contains("An error occurred", (result.Content[0] as TextContentBlock)?.Text);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "\"throw_operation_canceled_exception\" threw an unhandled exception." &&
+ m.Exception is OperationCanceledException);
+ }
+
+ [Fact]
+ public async Task Logs_Tool_Error_When_Tool_Throws_McpProtocolException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.CallToolAsync(
+ "throw_mcp_protocol_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "\"throw_mcp_protocol_exception\" threw an unhandled exception." &&
+ m.Exception is McpProtocolException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
[Fact]
public async Task Throws_Exception_On_Unknown_Tool()
{
@@ -786,6 +858,28 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
throw new InvalidOperationException("Test error");
}
+ [McpServerTool]
+ public static string ThrowOperationCanceledException()
+ {
+ throw new OperationCanceledException("Tool was canceled");
+ }
+
+ [McpServerTool]
+ public static string ThrowMcpProtocolException()
+ {
+ throw new McpProtocolException("Tool protocol error", McpErrorCode.InvalidParams);
+ }
+
+ [McpServerTool]
+ public static CallToolResult ReturnIsError()
+ {
+ return new CallToolResult
+ {
+ IsError = true,
+ Content = [new TextContentBlock { Text = "Tool returned an error" }],
+ };
+ }
+
[McpServerTool]
public static int ReturnCancellationToken(CancellationToken cancellationToken)
{
@@ -868,5 +962,6 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[JsonSerializable(typeof(ComplexObject))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(JsonElement))]
+ [JsonSerializable(typeof(CallToolResult))]
partial class BuilderToolsJsonContext : JsonSerializerContext;
}
\ No newline at end of file
diff --git a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs b/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
index 0bf70b7..99c6035 100644
--- a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
@@ -530,6 +530,129 @@ public class ToolTaskSupportTests : LoggedTest
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
Assert.Contains("task", exception.Message, StringComparison.OrdinalIgnoreCase);
}
+
+ [Fact]
+ public async Task TaskPath_Logs_Tool_Name_On_Successful_Call()
+ {
+ var taskStore = new InMemoryMcpTaskStore();
+
+ await using var fixture = new ClientServerFixture(
+ LoggerFactory,
+ configureServer: builder =>
+ {
+ builder.WithTools([McpServerTool.Create(
+ (string input) => $"Result: {input}",
+ new McpServerToolCreateOptions
+ {
+ Name = "task-success-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ })]);
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(MockLoggerProvider);
+ services.AddSingleton(taskStore);
+ services.Configure(options => options.TaskStore = taskStore);
+ });
+
+ var mcpTask = await fixture.Client.CallToolAsTaskAsync(
+ "task-success-tool",
+ arguments: new Dictionary { ["input"] = "test" },
+ taskMetadata: new McpTaskMetadata(),
+ progress: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(mcpTask);
+
+ // Wait for the async task execution to complete
+ await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-success-tool\" completed. IsError = False.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task TaskPath_Logs_Tool_Name_With_IsError_When_Tool_Returns_Error()
+ {
+ var taskStore = new InMemoryMcpTaskStore();
+
+ await using var fixture = new ClientServerFixture(
+ LoggerFactory,
+ configureServer: builder =>
+ {
+ builder.WithTools([McpServerTool.Create(
+ () => new CallToolResult
+ {
+ IsError = true,
+ Content = [new TextContentBlock { Text = "Task tool error" }],
+ },
+ new McpServerToolCreateOptions
+ {
+ Name = "task-error-result-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ })]);
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(MockLoggerProvider);
+ services.AddSingleton(taskStore);
+ services.Configure(options => options.TaskStore = taskStore);
+ });
+
+ var mcpTask = await fixture.Client.CallToolAsTaskAsync(
+ "task-error-result-tool",
+ taskMetadata: new McpTaskMetadata(),
+ progress: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(mcpTask);
+
+ // Wait for the async task execution to complete
+ await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-error-result-tool\" completed. IsError = True.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task TaskPath_Logs_Error_When_Tool_Throws()
+ {
+ var taskStore = new InMemoryMcpTaskStore();
+
+ await using var fixture = new ClientServerFixture(
+ LoggerFactory,
+ configureServer: builder =>
+ {
+ builder.WithTools([McpServerTool.Create(
+ string () => throw new InvalidOperationException("Task tool error"),
+ new McpServerToolCreateOptions
+ {
+ Name = "task-throw-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ })]);
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(MockLoggerProvider);
+ services.AddSingleton(taskStore);
+ services.Configure(options => options.TaskStore = taskStore);
+ });
+
+ var mcpTask = await fixture.Client.CallToolAsTaskAsync(
+ "task-throw-tool",
+ taskMetadata: new McpTaskMetadata(),
+ progress: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(mcpTask);
+
+ // Wait for the async task execution to complete
+ await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
+
+ var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("\"task-throw-tool\" threw an unhandled exception.", errorLog.Message);
+ Assert.IsType(errorLog.Exception);
+ }
#pragma warning restore MCPEXP001
#endregion
From b8ae4fec2bcef1f63e552cc74d5b2955f85da8d4 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Feb 2026 09:26:00 -0800
Subject: [PATCH 036/320] Add auth regression test for missing resource in PRM
(#1265)
---
.../OAuth/AuthTests.cs | 42 +++++++++++++++++++
.../Program.cs | 12 +++++-
2 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index d400783..265090e 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
@@ -528,6 +529,47 @@ public class AuthTests : OAuthTestBase
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
+ [Fact]
+ public async Task CannotAuthenticate_WhenProtectedResourceMetadataMissingResource()
+ {
+ TestOAuthServer.RequireResource = false;
+
+ Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ options.Events.OnResourceMetadataRequest = async context =>
+ {
+ context.HandleResponse();
+
+ var metadata = new ProtectedResourceMetadata
+ {
+ AuthorizationServers = { new Uri(OAuthServerUrl) },
+ ScopesSupported = ["mcp:tools"],
+ };
+
+ await Results.Json(metadata, McpJsonUtilities.DefaultOptions).ExecuteAsync(context.HttpContext);
+ };
+ });
+
+ await using var app = await StartMcpServerAsync();
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ },
+ }, HttpClient, LoggerFactory);
+
+ var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains("Resource URI in metadata", ex.Message);
+ }
+
[Fact]
public async Task CanAuthenticate_WithAuthorizationServerPathInsertionMetadata()
{
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index d8923b7..94cc3a4 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -67,6 +67,14 @@ public sealed class Program
///
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
+ ///
+ /// Gets or sets a value indicating whether the authorization server requires a resource parameter.
+ ///
+ ///
+ /// The default value is true.
+ ///
+ public bool RequireResource { get; set; } = true;
+
public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray();
@@ -290,7 +298,7 @@ public sealed class Program
}
// Validate resource in accordance with RFC 8707
- if (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource))
+ if (RequireResource && (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)))
{
return Results.Redirect($"{redirect_uri}?error=invalid_target&error_description=The+specified+resource+is+not+valid&state={state}");
}
@@ -338,7 +346,7 @@ public sealed class Program
// Validate resource in accordance with RFC 8707
var resource = form["resource"].ToString();
- if (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource))
+ if (RequireResource && (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)))
{
return Results.BadRequest(new OAuthErrorResponse
{
From ec2983b29f381283f84c9eeb1b5e32108564a193 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Feb 2026 13:31:37 -0500
Subject: [PATCH 037/320] Fix test for CannotAuthenticate when metadata is
missing (#1266)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
---
tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index 265090e..9099e2b 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -542,7 +542,7 @@ public class AuthTests : OAuthTestBase
var metadata = new ProtectedResourceMetadata
{
- AuthorizationServers = { new Uri(OAuthServerUrl) },
+ AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
From fb2daf2f0dce8c9cb5e377dab98cef68f562402f Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sat, 14 Feb 2026 10:19:53 -0500
Subject: [PATCH 038/320] Fix HttpClient timeout for long-running tools without
event stream store (#1268)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
---
.../Server/StreamableHttpPostTransport.cs | 7 ++
.../MapMcpTests.cs | 57 +++++++++
.../OAuth/AuthTests.cs | 116 +++++++++++++-----
.../OAuth/OAuthTestBase.cs | 6 +-
.../StreamableHttpServerConformanceTests.cs | 27 ++--
5 files changed, 172 insertions(+), 41 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
index c298bb5..7f399ba 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
@@ -74,6 +74,13 @@ internal sealed partial class StreamableHttpPostTransport(
{
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
}
+ else
+ {
+ // If there's no priming write, flush the stream to ensure HTTP response headers are
+ // sent to the client now that the server is ready to process the request.
+ // This prevents HttpClient timeout for long-running requests.
+ await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
// Ensure that we've sent the priming event before processing the incoming request.
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
index a80183a..a032494 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
@@ -231,6 +231,50 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
"This suggests the GET request is not respecting ApplicationStopping token.");
}
+ [Fact]
+ public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore()
+ {
+ // Regression test for: Tool calls that last over HttpClient timeout without producing
+ // intermediate notifications will timeout because HttpClient doesn't see the 200 response
+ // until the first message is written. When primingItem is null (no ISseEventStreamStore),
+ // we should flush the response stream so HttpClient sees the 200 immediately.
+
+ Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools();
+
+ await using var app = Builder.Build();
+ app.MapMcp();
+ await app.StartAsync(TestContext.Current.CancellationToken);
+
+ // Create a custom HttpClient with a very short timeout (1 second)
+ // The tool will take 2 seconds to complete
+ using var shortTimeoutClient = new HttpClient(SocketsHttpHandler)
+ {
+ BaseAddress = new Uri("http://localhost:5000/"),
+ Timeout = TimeSpan.FromSeconds(1)
+ };
+
+ var path = UseStreamableHttp ? "/" : "/sse";
+ var transportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new($"http://localhost:5000{path}"),
+ TransportMode = transportMode,
+ }, shortTimeoutClient, LoggerFactory);
+
+ await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+
+ // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout
+ // because the response stream is flushed immediately after receiving the request
+ var response = await mcpClient.CallToolAsync(
+ "long_running_operation",
+ new Dictionary() { ["durationMs"] = 2000 },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var content = Assert.Single(response.Content.OfType());
+ Assert.Equal("Operation completed after 2000ms", content.Text);
+ }
+
private ClaimsPrincipal CreateUser(string name)
=> new(new ClaimsIdentity(
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
@@ -288,4 +332,17 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
return $"Sampling completed successfully. Client responded: {Assert.IsType(Assert.Single(samplingResult.Content)).Text}";
}
}
+
+ [McpServerToolType]
+ protected class LongRunningTools
+ {
+ [McpServerTool, Description("Simulates a long-running operation")]
+ public static async Task LongRunningOperation(
+ [Description("Duration of the operation in milliseconds")] int durationMs,
+ CancellationToken cancellationToken)
+ {
+ await Task.Delay(durationMs, cancellationToken);
+ return $"Operation completed after {durationMs}ms";
+ }
+ }
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index 9099e2b..331f814 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
@@ -8,10 +9,12 @@ using ModelContextProtocol;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
+using System.Text.Json;
using Xunit.Sdk;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
@@ -211,32 +214,46 @@ public class AuthTests : OAuthTestBase
{
var hasForcedRefresh = false;
- Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer(options =>
+ {
+ options.ToolCollection = new();
+ });
+
+ await using var app = await StartMcpServerAsync(configureMiddleware: app =>
+ {
+ // Add middleware to intercept list tools requests and force a token refresh on the first call
+ app.Use(async (context, next) =>
{
- options.ToolCollection = new();
- })
- .AddListToolsFilter(next =>
- {
- return async (mcpContext, cancellationToken) =>
+ if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/" && !hasForcedRefresh)
{
- if (!hasForcedRefresh)
+ // Enable buffering so we can read the request body multiple times
+ context.Request.EnableBuffering();
+
+ // Read the request body to check if it's calling tools/list
+ var message = await JsonSerializer.DeserializeAsync(
+ context.Request.Body,
+ McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
+ context.RequestAborted) as JsonRpcMessage;
+
+ // Reset the request body position so MapMcp can read it
+ context.Request.Body.Position = 0;
+
+ // Check if this is a tools/list request
+ if (message is JsonRpcRequest request && request.Method == "tools/list")
{
hasForcedRefresh = true;
- var httpContext = mcpContext.Services!.GetRequiredService().HttpContext!;
- await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
- await httpContext.Response.CompleteAsync();
- throw new Exception("This exception will not impact the client because the response has already been completed.");
+ // Return 401 to force token refresh
+ await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
+ await context.Response.StartAsync(context.RequestAborted);
+ await context.Response.Body.FlushAsync(context.RequestAborted);
+ return; // Short-circuit, don't call next()
}
- else
- {
- return await next(mcpContext, cancellationToken);
- }
- };
- });
+ }
- await using var app = await StartMcpServerAsync();
+ await next(context);
+ });
+ });
await using var transport = new HttpClientTransport(new()
{
@@ -451,29 +468,66 @@ public class AuthTests : OAuthTestBase
{
var adminScopes = "admin:read admin:write";
- Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer()
.WithTools([
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
- async (IServiceProvider serviceProvider, ClaimsPrincipal user) =>
+ (ClaimsPrincipal user) =>
{
- if (!user.HasClaim("scope", adminScopes))
- {
- var httpContext = serviceProvider.GetRequiredService().HttpContext!;
- httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
- httpContext.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
- await httpContext.Response.CompleteAsync();
-
- throw new Exception("This exception will not impact the client because the response has already been completed.");
- }
-
+ // Tool now just checks if user has the required scopes
+ // If they don't, it shouldn't get here due to middleware
+ Assert.True(user.HasClaim("scope", adminScopes), "User should have admin scopes when tool executes");
return "Admin tool executed.";
}),
]);
string? requestedScope = null;
- await using var app = await StartMcpServerAsync();
+ await using var app = await StartMcpServerAsync(configureMiddleware: app =>
+ {
+ // Add middleware to intercept requests and check for admin-tool calls
+ app.Use(async (context, next) =>
+ {
+ if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/")
+ {
+ // Enable buffering so we can read the request body multiple times
+ context.Request.EnableBuffering();
+
+ // Read the request body to check if it's calling admin-tool
+ var message = await JsonSerializer.DeserializeAsync(
+ context.Request.Body,
+ McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
+ context.RequestAborted) as JsonRpcMessage;
+
+ // Reset the request body position so MapMcp can read it
+ context.Request.Body.Position = 0;
+
+ // Check if this is a tools/call request for admin-tool
+ if (message is JsonRpcRequest request && request.Method == "tools/call")
+ {
+ var toolCallParams = JsonSerializer.Deserialize(
+ request.Params,
+ McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams;
+
+ if (toolCallParams?.Name == "admin-tool")
+ {
+ // Check if user has required scopes
+ var user = context.User;
+ if (!user.HasClaim("scope", adminScopes))
+ {
+ // User lacks required scopes, return 403 before MapMcp processes the request
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
+ await context.Response.StartAsync(context.RequestAborted);
+ await context.Response.Body.FlushAsync(context.RequestAborted);
+ return; // Short-circuit, don't call next()
+ }
+ }
+ }
+ }
+
+ await next(context);
+ });
+ });
await using var transport = new HttpClientTransport(new()
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
index f744b77..3c1919b 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
@@ -81,7 +81,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
}
}
- protected async Task StartMcpServerAsync(string path = "", string? authScheme = null)
+ protected async Task StartMcpServerAsync(string path = "", string? authScheme = null, Action? configureMiddleware = null)
{
// Wait for the OAuth server to be ready before starting the MCP server.
// This prevents race conditions in CI where the OAuth server may not be
@@ -94,6 +94,10 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
});
var app = Builder.Build();
+
+ // Allow tests to add custom middleware before MapMcp
+ configureMiddleware?.Invoke(app);
+
app.MapMcp(path).RequireAuthorization(new AuthorizeAttribute
{
AuthenticationSchemes = authScheme
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
index 35e17be..0c42b3e 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
@@ -350,7 +350,13 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
await CallInitializeAndValidateAsync();
Task CallLongRunningToolAsync() =>
- HttpClient.PostAsync("", JsonContent(CallTool("long-running")), TestContext.Current.CancellationToken);
+ HttpClient.SendAsync(
+ new HttpRequestMessage(HttpMethod.Post, "")
+ {
+ Content = JsonContent(CallTool("long-running"))
+ },
+ HttpCompletionOption.ResponseHeadersRead,
+ TestContext.Current.CancellationToken);
var longRunningToolTasks = new Task[10];
for (int i = 0; i < longRunningToolTasks.Length; i++)
@@ -360,25 +366,28 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
- for (int i = 0; i < longRunningToolTasks.Length; i++)
+ // Wait for all long-running tool calls to receive 200 response headers before sending DELETE
+ var responseHeaders = await Task.WhenAll(longRunningToolTasks);
+ foreach (var response in responseHeaders)
{
- Assert.False(longRunningToolTasks[i].IsCompleted);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
+ // Now send DELETE to cancel the session
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
// Get request should complete gracefully.
var sseResponseBody = await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Empty(sseResponseBody);
- // Currently, the OCE thrown by the canceled session is unhandled and turned into a 500 error by Kestrel.
+ // Currently, responses are flushed immediately to prevent HttpClient timeouts for long-running requests.
+ // This means the response starts with a 200 status code. When the session is canceled, Kestrel closes
+ // the connection without writing the chunk terminator, causing an HttpRequestException when reading the response body.
// The spec suggests sending CancelledNotifications. That would be good, but we can do that later.
- // For now, the important thing is that request completes without indicating success.
- await Task.WhenAll(longRunningToolTasks);
- foreach (var task in longRunningToolTasks)
+ // For now, the important thing is that reading the response body fails.
+ foreach (var response in responseHeaders)
{
- var response = await task;
- Assert.False(response.IsSuccessStatusCode);
+ await Assert.ThrowsAsync(async () => await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}
}
From f76f6f65c52a9dc23c97e6f8cb07f3d8e088a6aa Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 15 Feb 2026 09:28:38 -0500
Subject: [PATCH 039/320] Bump qs from 6.14.1 to 6.14.2 in the npm_and_yarn
group across 1 directory (#1271)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 5a2d798..aca8545 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -939,9 +939,9 @@
}
},
"node_modules/qs": {
- "version": "6.14.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
- "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
From 3caac28a76b0780a81f0fc8ec2d36a995bd0637b Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sun, 15 Feb 2026 09:55:24 -0500
Subject: [PATCH 040/320] Fix flaky stderr test: call WaitForExit() after
WaitForExitAsync() to flush ErrorDataReceived events (#1278)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Client/StdioClientSessionTransport.cs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
index 78b0d6f..f8b1074 100644
--- a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
@@ -72,11 +72,13 @@ internal sealed class StdioClientSessionTransport(
try
{
// The process has exited, but we still need to ensure stderr has been flushed.
+ // WaitForExitAsync only waits for exit; it does not guarantee that all
+ // ErrorDataReceived events have been dispatched. The synchronous WaitForExit()
+ // (no arguments) does ensure that, so call it after WaitForExitAsync completes.
#if NET
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
-#else
- _process.WaitForExit();
#endif
+ _process.WaitForExit();
}
catch { }
From 1dd06e73dc2d68e602885e45b1382127e663980c Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sun, 15 Feb 2026 16:33:09 -0500
Subject: [PATCH 041/320] Fix stderr event loss due to missing WaitForExit in
DisposeProcess (#1280)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Client/StdioClientTransport.cs | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs
index 95f5221..191a512 100644
--- a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs
@@ -227,6 +227,18 @@ public sealed partial class StdioClientTransport : IClientTransport
// and Node.js does not kill its children when it exits properly.
process.KillTree(shutdownTimeout);
}
+
+ // Ensure all redirected stderr/stdout events have been dispatched
+ // before disposing. Only the no-arg WaitForExit() guarantees this;
+ // WaitForExit(int) (as used by KillTree) does not.
+ // This should not hang: either the process already exited on its own
+ // (no child processes holding handles), or KillTree killed the entire
+ // process tree. If it does take too long, the test infrastructure's
+ // own timeout will catch it.
+ if (!processRunning && HasExited(process))
+ {
+ process.WaitForExit();
+ }
}
finally
{
From 5cec467e0337c64ac8d644394bc63a0637041abc Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 16 Feb 2026 15:12:03 +0200
Subject: [PATCH 042/320] Bump the other-testing group with 2 updates (#1283)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Directory.Packages.props | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8a74f47..0aac891 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,7 +3,7 @@
true
8.0.22
9.0.11
- 10.0.2
+ 10.0.3
10.2.0
@@ -92,6 +92,6 @@
-
+
\ No newline at end of file
From da91b641cce7fa6dcb08101d71ff97e85e7d4792 Mon Sep 17 00:00:00 2001
From: Mike Kistler
Date: Mon, 16 Feb 2026 07:18:34 -0600
Subject: [PATCH 043/320] Unskip GetRequest_Receives_UnsolicitedNotifications
test (#1286)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../StreamableHttpServerConformanceTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
index 0c42b3e..a96c121 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
@@ -249,7 +249,7 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
await Task.WhenAll(echoTasks);
}
- [Fact(Skip = "https://github.com/modelcontextprotocol/csharp-sdk/issues/1211")]
+ [Fact]
public async Task GetRequest_Receives_UnsolicitedNotifications()
{
McpServer? server = null;
From 2c7eefad1f1fc3c9cfbad28dc905741fb60f68e4 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 16 Feb 2026 15:30:10 +0200
Subject: [PATCH 044/320] Bump Anthropic from 12.3.0 to 12.5.0 (#1284)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0aac891..a7bf4a3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -62,7 +62,7 @@
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
From d7ca79b4d8aeef7aec956b8c8f7de064997cd693 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 16 Feb 2026 16:23:15 +0200
Subject: [PATCH 045/320] Bump Microsoft.Extensions.AI from 10.2.0 to 10.3.0
(#1285)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a7bf4a3..3f5540f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
8.0.22
9.0.11
10.0.3
- 10.2.0
+ 10.3.0
From 392e47ea29f78785c09fd1f30b20d5115b22af42 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 09:30:11 -0500
Subject: [PATCH 046/320] Document https:// direct-fetch provision in
ReadResourceRequestParams (#1296)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Protocol/ReadResourceRequestParams.cs | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/ModelContextProtocol.Core/Protocol/ReadResourceRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ReadResourceRequestParams.cs
index 12b752a..6d358e9 100644
--- a/src/ModelContextProtocol.Core/Protocol/ReadResourceRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ReadResourceRequestParams.cs
@@ -7,8 +7,18 @@ namespace ModelContextProtocol.Protocol;
/// Represents the parameters used with a request from a client to get a resource provided by a server.
///
///
+///
/// The server will respond with a containing the resulting resource data.
+///
+///
+/// Alternatively, if the resource URI uses the https:// scheme, clients may fetch the resource
+/// directly from the web instead of using .
+/// Servers should only use the https:// scheme when the client is able to fetch and load the
+/// resource directly on its own.
+///
+///
/// See the schema for details.
+///
///
public sealed class ReadResourceRequestParams : RequestParams
{
From 4cef394ad53d286dad5de948d153b289b2a3c135 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 12:34:49 -0800
Subject: [PATCH 047/320] Add serialization roundtrip tests for all Protocol
namespace types (#1289)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Protocol/AnnotationsTests.cs | 43 ++
.../Protocol/ArgumentTests.cs | 24 +
.../Protocol/CallToolRequestParamsTests.cs | 56 +++
.../Protocol/CallToolResultTests.cs | 58 +++
.../CancelMcpTaskRequestParamsTests.cs | 25 +
.../Protocol/CancelMcpTaskResultTests.cs | 33 ++
.../CancelledNotificationParamsTests.cs | 45 ++
.../Protocol/ClientCapabilitiesTests.cs | 57 +++
.../Protocol/CompleteContextTests.cs | 41 ++
.../Protocol/CompleteRequestParamsTests.cs | 58 +++
.../Protocol/CompleteResultTests.cs | 53 +++
.../Protocol/CompletionTests.cs | 43 ++
.../Protocol/CreateMessageResultTests.cs | 1 +
.../Protocol/CreateTaskResultTests.cs | 41 ++
.../Protocol/ElicitRequestParamsTests.cs | 89 ++++
.../Protocol/ElicitResultTests.cs | 51 +++
.../Protocol/ElicitationCapabilityTests.cs | 38 ++
...citationCompleteNotificationParamsTests.cs | 42 ++
.../Protocol/EmptyResultTests.cs | 36 ++
.../Protocol/GetPromptRequestParamsTests.cs | 49 ++
.../Protocol/GetPromptResultTests.cs | 60 +++
.../GetTaskPayloadRequestParamsTests.cs | 25 +
.../Protocol/GetTaskRequestParamsTests.cs | 25 +
.../Protocol/GetTaskResultTests.cs | 37 ++
.../Protocol/InitializeRequestParamsTests.cs | 42 ++
.../Protocol/InitializeResultTests.cs | 69 +++
.../InitializedNotificationParamsTests.cs | 36 ++
.../Protocol/JsonRpcErrorTests.cs | 55 +++
.../Protocol/ListPromptsRequestParamsTests.cs | 39 ++
.../Protocol/ListPromptsResultTests.cs | 58 +++
...ListResourceTemplatesRequestParamsTests.cs | 39 ++
.../ListResourceTemplatesResultTests.cs | 55 +++
.../ListResourcesRequestParamsTests.cs | 39 ++
.../Protocol/ListResourcesResultTests.cs | 53 +++
.../Protocol/ListRootsRequestParamsTests.cs | 36 ++
.../Protocol/ListRootsResultTests.cs | 34 ++
.../Protocol/ListTasksRequestParamsTests.cs | 25 +
.../Protocol/ListTasksResultTests.cs | 46 ++
.../Protocol/ListToolsRequestParamsTests.cs | 39 ++
.../Protocol/ListToolsResultTests.cs | 50 ++
.../LoggingMessageNotificationParamsTests.cs | 49 ++
.../Protocol/McpTaskMetadataTests.cs | 53 +++
.../McpTaskStatusNotificationParamsTests.cs | 37 ++
.../Protocol/McpTaskTests.cs | 428 ------------------
.../Protocol/McpTasksCapabilityTests.cs | 91 ++++
.../Protocol/ModelPreferencesTests.cs | 74 +++
.../Protocol/PingRequestParamsTests.cs | 36 ++
.../Protocol/PingResultTests.cs | 36 ++
.../Protocol/PromptArgumentTests.cs | 46 ++
...romptListChangedNotificationParamsTests.cs | 36 ++
.../Protocol/PromptMessageTests.cs | 44 ++
.../Protocol/PromptsCapabilityTests.cs | 34 ++
.../Protocol/ProtocolTypeTests.cs | 47 --
.../ReadResourceRequestParamsTests.cs | 42 ++
.../Protocol/ReadResourceResultTests.cs | 64 +++
.../RequestMcpTasksCapabilityTests.cs | 108 +++++
...ourceListChangedNotificationParamsTests.cs | 36 ++
.../Protocol/ResourceTemplateTests.cs | 75 +++
.../ResourceUpdatedNotificationParamsTests.cs | 42 ++
.../Protocol/ResourcesCapabilityTests.cs | 37 ++
.../Protocol/RootTests.cs | 44 ++
.../Protocol/RootsCapabilityTests.cs | 34 ++
...RootsListChangedNotificationParamsTests.cs | 36 ++
.../Protocol/SamplingCapabilityTests.cs | 37 ++
.../Protocol/ServerCapabilitiesTests.cs | 54 +++
.../Protocol/SetLevelRequestParamsTests.cs | 42 ++
.../Protocol/SubscribeRequestParamsTests.cs | 42 ++
.../Protocol/ToolAnnotationsTests.cs | 46 ++
.../ToolListChangedNotificationParamsTests.cs | 36 ++
.../Protocol/ToolTests.cs | 47 ++
.../Protocol/ToolsCapabilityTests.cs | 34 ++
.../Protocol/UnsubscribeRequestParamsTests.cs | 42 ++
.../UrlElicitationRequiredErrorDataTests.cs | 43 ++
73 files changed, 3222 insertions(+), 475 deletions(-)
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/AnnotationsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ArgumentTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CancelledNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CompleteContextTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CompleteRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CompleteResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CompletionTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitationCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitationCompleteNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/EmptyResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetPromptRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetPromptResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/InitializeRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/InitializeResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/InitializedNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/JsonRpcErrorTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListPromptsRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListPromptsResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListResourcesRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListResourcesResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListRootsRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListRootsResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListToolsRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListToolsResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/LoggingMessageNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ModelPreferencesTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/PingRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/PingResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/PromptArgumentTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/PromptListChangedNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/PromptMessageTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/PromptsCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ReadResourceRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ReadResourceResultTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ResourceListChangedNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ResourceTemplateTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ResourceUpdatedNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ResourcesCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/RootTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/RootsCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/RootsListChangedNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/SamplingCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/SetLevelRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/SubscribeRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ToolAnnotationsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ToolListChangedNotificationParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ToolsCapabilityTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/UnsubscribeRequestParamsTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/Protocol/UrlElicitationRequiredErrorDataTests.cs
diff --git a/tests/ModelContextProtocol.Tests/Protocol/AnnotationsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/AnnotationsTests.cs
new file mode 100644
index 0000000..700d5d0
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/AnnotationsTests.cs
@@ -0,0 +1,43 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class AnnotationsTests
+{
+ [Fact]
+ public static void Annotations_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new Annotations
+ {
+ Audience = [Role.User, Role.Assistant],
+ Priority = 0.75f,
+ LastModified = new DateTimeOffset(2025, 6, 15, 10, 30, 0, TimeSpan.Zero)
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Audience);
+ Assert.Equal(2, deserialized.Audience.Count);
+ Assert.Equal(Role.User, deserialized.Audience[0]);
+ Assert.Equal(Role.Assistant, deserialized.Audience[1]);
+ Assert.Equal(original.Priority, deserialized.Priority);
+ Assert.Equal(original.LastModified, deserialized.LastModified);
+ }
+
+ [Fact]
+ public static void Annotations_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new Annotations();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Audience);
+ Assert.Null(deserialized.Priority);
+ Assert.Null(deserialized.LastModified);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ArgumentTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ArgumentTests.cs
new file mode 100644
index 0000000..0259497
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ArgumentTests.cs
@@ -0,0 +1,24 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ArgumentTests
+{
+ [Fact]
+ public static void Argument_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new Argument
+ {
+ Name = "temperature",
+ Value = "72"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.Name, deserialized.Name);
+ Assert.Equal(original.Value, deserialized.Value);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs
new file mode 100644
index 0000000..d2f5a09
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs
@@ -0,0 +1,56 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CallToolRequestParamsTests
+{
+ [Fact]
+ public static void CallToolRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new CallToolRequestParams
+ {
+ Name = "get_weather",
+ Arguments = new Dictionary
+ {
+ ["city"] = JsonDocument.Parse("\"Seattle\"").RootElement.Clone(),
+ ["units"] = JsonDocument.Parse("\"metric\"").RootElement.Clone()
+ },
+ Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromHours(1) },
+ Meta = new JsonObject { ["progressToken"] = "token-123" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.Name, deserialized.Name);
+ Assert.NotNull(deserialized.Arguments);
+ Assert.Equal(2, deserialized.Arguments.Count);
+ Assert.Equal("Seattle", deserialized.Arguments["city"].GetString());
+ Assert.Equal("metric", deserialized.Arguments["units"].GetString());
+ Assert.NotNull(deserialized.Task);
+ Assert.Equal(original.Task.TimeToLive, deserialized.Task.TimeToLive);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("token-123", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void CallToolRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new CallToolRequestParams
+ {
+ Name = "simple_tool"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.Name, deserialized.Name);
+ Assert.Null(deserialized.Arguments);
+ Assert.Null(deserialized.Task);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs
new file mode 100644
index 0000000..29ecbcb
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs
@@ -0,0 +1,58 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CallToolResultTests
+{
+ [Fact]
+ public static void CallToolResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "Result text" }],
+ StructuredContent = JsonNode.Parse("""{"temperature":72}"""),
+ IsError = false,
+ Task = new McpTask
+ {
+ TaskId = "task-1",
+ Status = McpTaskStatus.Completed,
+ CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
+ LastUpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
+ },
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Single(deserialized.Content);
+ var textBlock = Assert.IsType(deserialized.Content[0]);
+ Assert.Equal("Result text", textBlock.Text);
+ Assert.NotNull(deserialized.StructuredContent);
+ Assert.Equal(72, deserialized.StructuredContent["temperature"]!.GetValue());
+ Assert.False(deserialized.IsError);
+ Assert.NotNull(deserialized.Task);
+ Assert.Equal("task-1", deserialized.Task.TaskId);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void CallToolResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new CallToolResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Empty(deserialized.Content);
+ Assert.Null(deserialized.StructuredContent);
+ Assert.Null(deserialized.IsError);
+ Assert.Null(deserialized.Task);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs
new file mode 100644
index 0000000..a3b3b2e
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs
@@ -0,0 +1,25 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CancelMcpTaskRequestParamsTests
+{
+ [Fact]
+ public static void CancelMcpTaskRequestParams_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new CancelMcpTaskRequestParams
+ {
+ TaskId = "cancel-task-456"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.TaskId, deserialized.TaskId);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs
new file mode 100644
index 0000000..5cf6286
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs
@@ -0,0 +1,33 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CancelMcpTaskResultTests
+{
+ [Fact]
+ public static void CancelMcpTaskResult_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new CancelMcpTaskResult
+ {
+ TaskId = "cancelled-789",
+ Status = McpTaskStatus.Cancelled,
+ StatusMessage = "Cancelled by user",
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ TimeToLive = null,
+ PollInterval = null
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.TaskId, deserialized.TaskId);
+ Assert.Equal(original.Status, deserialized.Status);
+ Assert.Equal(original.StatusMessage, deserialized.StatusMessage);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancelledNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancelledNotificationParamsTests.cs
new file mode 100644
index 0000000..d24bf10
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CancelledNotificationParamsTests.cs
@@ -0,0 +1,45 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CancelledNotificationParamsTests
+{
+ [Fact]
+ public static void CancelledNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new CancelledNotificationParams
+ {
+ RequestId = new RequestId(42),
+ Reason = "User cancelled the operation",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.RequestId, deserialized.RequestId);
+ Assert.Equal(original.Reason, deserialized.Reason);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void CancelledNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new CancelledNotificationParams
+ {
+ RequestId = new RequestId("req-123")
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.RequestId, deserialized.RequestId);
+ Assert.Null(deserialized.Reason);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs
new file mode 100644
index 0000000..188326a
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs
@@ -0,0 +1,57 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ClientCapabilitiesTests
+{
+ [Fact]
+ public static void ClientCapabilities_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ClientCapabilities
+ {
+ Roots = new RootsCapability { ListChanged = true },
+ Sampling = new SamplingCapability
+ {
+ Context = new SamplingContextCapability(),
+ Tools = new SamplingToolsCapability()
+ },
+ Elicitation = new ElicitationCapability
+ {
+ Form = new FormElicitationCapability(),
+ Url = new UrlElicitationCapability()
+ },
+ Tasks = new McpTasksCapability()
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Roots);
+ Assert.True(deserialized.Roots.ListChanged);
+ Assert.NotNull(deserialized.Sampling);
+ Assert.NotNull(deserialized.Sampling.Context);
+ Assert.NotNull(deserialized.Sampling.Tools);
+ Assert.NotNull(deserialized.Elicitation);
+ Assert.NotNull(deserialized.Elicitation.Form);
+ Assert.NotNull(deserialized.Elicitation.Url);
+ Assert.NotNull(deserialized.Tasks);
+ }
+
+ [Fact]
+ public static void ClientCapabilities_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ClientCapabilities();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Experimental);
+ Assert.Null(deserialized.Roots);
+ Assert.Null(deserialized.Sampling);
+ Assert.Null(deserialized.Elicitation);
+ Assert.Null(deserialized.Tasks);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CompleteContextTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CompleteContextTests.cs
new file mode 100644
index 0000000..1b000cc
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CompleteContextTests.cs
@@ -0,0 +1,41 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CompleteContextTests
+{
+ [Fact]
+ public static void CompleteContext_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new CompleteContext
+ {
+ Arguments = new Dictionary
+ {
+ ["language"] = "en",
+ ["region"] = "us"
+ }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Arguments);
+ Assert.Equal(2, deserialized.Arguments.Count);
+ Assert.Equal("en", deserialized.Arguments["language"]);
+ Assert.Equal("us", deserialized.Arguments["region"]);
+ }
+
+ [Fact]
+ public static void CompleteContext_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new CompleteContext();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Arguments);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CompleteRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CompleteRequestParamsTests.cs
new file mode 100644
index 0000000..5ce896e
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CompleteRequestParamsTests.cs
@@ -0,0 +1,58 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CompleteRequestParamsTests
+{
+ [Fact]
+ public static void CompleteRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new CompleteRequestParams
+ {
+ Ref = new PromptReference { Name = "my_prompt" },
+ Argument = new Argument { Name = "topic", Value = "wea" },
+ Context = new CompleteContext
+ {
+ Arguments = new Dictionary { ["language"] = "en" }
+ },
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ var promptRef = Assert.IsType(deserialized.Ref);
+ Assert.Equal("my_prompt", promptRef.Name);
+ Assert.Equal(original.Argument.Name, deserialized.Argument.Name);
+ Assert.Equal(original.Argument.Value, deserialized.Argument.Value);
+ Assert.NotNull(deserialized.Context);
+ Assert.NotNull(deserialized.Context.Arguments);
+ Assert.Equal("en", deserialized.Context.Arguments["language"]);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void CompleteRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new CompleteRequestParams
+ {
+ Ref = new ResourceTemplateReference { Uri = "file:///{path}" },
+ Argument = new Argument { Name = "path", Value = "/ho" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ var resourceRef = Assert.IsType(deserialized.Ref);
+ Assert.Equal("file:///{path}", resourceRef.Uri);
+ Assert.Equal("path", deserialized.Argument.Name);
+ Assert.Equal("/ho", deserialized.Argument.Value);
+ Assert.Null(deserialized.Context);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CompleteResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CompleteResultTests.cs
new file mode 100644
index 0000000..39239e9
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CompleteResultTests.cs
@@ -0,0 +1,53 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CompleteResultTests
+{
+ [Fact]
+ public static void CompleteResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new CompleteResult
+ {
+ Completion = new Completion
+ {
+ Values = ["weather", "web", "webhook"],
+ Total = 10,
+ HasMore = true
+ },
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Completion);
+ Assert.Equal(3, deserialized.Completion.Values.Count);
+ Assert.Equal("weather", deserialized.Completion.Values[0]);
+ Assert.Equal("web", deserialized.Completion.Values[1]);
+ Assert.Equal("webhook", deserialized.Completion.Values[2]);
+ Assert.Equal(10, deserialized.Completion.Total);
+ Assert.True(deserialized.Completion.HasMore);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void CompleteResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new CompleteResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Completion);
+ Assert.Empty(deserialized.Completion.Values);
+ Assert.Null(deserialized.Completion.Total);
+ Assert.Null(deserialized.Completion.HasMore);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CompletionTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CompletionTests.cs
new file mode 100644
index 0000000..44c5955
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CompletionTests.cs
@@ -0,0 +1,43 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CompletionTests
+{
+ [Fact]
+ public static void Completion_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new Completion
+ {
+ Values = ["option1", "option2", "option3"],
+ Total = 50,
+ HasMore = true
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(3, deserialized.Values.Count);
+ Assert.Equal("option1", deserialized.Values[0]);
+ Assert.Equal("option2", deserialized.Values[1]);
+ Assert.Equal("option3", deserialized.Values[2]);
+ Assert.Equal(50, deserialized.Total);
+ Assert.True(deserialized.HasMore);
+ }
+
+ [Fact]
+ public static void Completion_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new Completion();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Empty(deserialized.Values);
+ Assert.Null(deserialized.Total);
+ Assert.Null(deserialized.HasMore);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs
index 67ab5f4..6648116 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs
@@ -164,6 +164,7 @@ public class CreateMessageResultTests
Assert.Equal(2, deserialized.Content.Count);
Assert.Equal("toolUse", deserialized.StopReason);
Assert.NotNull(deserialized.Meta);
+ Assert.Equal("metadata", (string)deserialized.Meta["custom"]!);
}
[Fact]
diff --git a/tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs
new file mode 100644
index 0000000..0252053
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs
@@ -0,0 +1,41 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class CreateTaskResultTests
+{
+ [Fact]
+ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new CreateTaskResult
+ {
+ Task = new McpTask
+ {
+ TaskId = "task-123",
+ Status = McpTaskStatus.Working,
+ StatusMessage = "Processing",
+ CreatedAt = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero),
+ LastUpdatedAt = new DateTimeOffset(2025, 6, 1, 12, 5, 0, TimeSpan.Zero),
+ TimeToLive = TimeSpan.FromHours(1),
+ PollInterval = TimeSpan.FromSeconds(5)
+ },
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("task-123", deserialized.Task.TaskId);
+ Assert.Equal(McpTaskStatus.Working, deserialized.Task.Status);
+ Assert.Equal("Processing", deserialized.Task.StatusMessage);
+ Assert.Equal(original.Task.CreatedAt, deserialized.Task.CreatedAt);
+ Assert.Equal(original.Task.LastUpdatedAt, deserialized.Task.LastUpdatedAt);
+ Assert.Equal(original.Task.TimeToLive, deserialized.Task.TimeToLive);
+ Assert.Equal(original.Task.PollInterval, deserialized.Task.PollInterval);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs
new file mode 100644
index 0000000..1d57f55
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs
@@ -0,0 +1,89 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ElicitRequestParamsTests
+{
+ [Fact]
+ public static void ElicitRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ElicitRequestParams
+ {
+ Mode = "form",
+ ElicitationId = "elicit-123",
+ Url = null,
+ Message = "Please provide your details",
+ RequestedSchema = new ElicitRequestParams.RequestSchema
+ {
+ Properties =
+ {
+ ["name"] = new ElicitRequestParams.StringSchema { Description = "Your name" },
+ ["age"] = new ElicitRequestParams.NumberSchema { Description = "Your age" }
+ }
+ },
+ Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) },
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("form", deserialized.Mode);
+ Assert.Equal("elicit-123", deserialized.ElicitationId);
+ Assert.Null(deserialized.Url);
+ Assert.Equal("Please provide your details", deserialized.Message);
+ Assert.NotNull(deserialized.RequestedSchema);
+ Assert.Equal(2, deserialized.RequestedSchema.Properties.Count);
+ Assert.NotNull(deserialized.Task);
+ Assert.Equal(TimeSpan.FromMinutes(10), deserialized.Task.TimeToLive);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void ElicitRequestParams_SerializationRoundTrip_UrlMode()
+ {
+ var original = new ElicitRequestParams
+ {
+ Mode = "url",
+ ElicitationId = "elicit-456",
+ Url = "https://example.com/auth",
+ Message = "Please authenticate"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("url", deserialized.Mode);
+ Assert.Equal("elicit-456", deserialized.ElicitationId);
+ Assert.Equal("https://example.com/auth", deserialized.Url);
+ Assert.Equal("Please authenticate", deserialized.Message);
+ Assert.Null(deserialized.RequestedSchema);
+ Assert.Null(deserialized.Task);
+ }
+
+ [Fact]
+ public static void ElicitRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ElicitRequestParams
+ {
+ Message = "Confirm action"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("form", deserialized.Mode);
+ Assert.Equal("Confirm action", deserialized.Message);
+ Assert.Null(deserialized.ElicitationId);
+ Assert.Null(deserialized.Url);
+ Assert.Null(deserialized.RequestedSchema);
+ Assert.Null(deserialized.Task);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitResultTests.cs
new file mode 100644
index 0000000..83a5a82
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitResultTests.cs
@@ -0,0 +1,51 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ElicitResultTests
+{
+ [Fact]
+ public static void ElicitResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ElicitResult
+ {
+ Action = "accept",
+ Content = new Dictionary
+ {
+ ["name"] = JsonDocument.Parse("\"John\"").RootElement.Clone(),
+ ["age"] = JsonDocument.Parse("30").RootElement.Clone()
+ },
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("accept", deserialized.Action);
+ Assert.True(deserialized.IsAccepted);
+ Assert.NotNull(deserialized.Content);
+ Assert.Equal(2, deserialized.Content.Count);
+ Assert.Equal("John", deserialized.Content["name"].GetString());
+ Assert.Equal(30, deserialized.Content["age"].GetInt32());
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ElicitResult_SerializationRoundTrip_WithDefaultAction()
+ {
+ var original = new ElicitResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("cancel", deserialized.Action);
+ Assert.False(deserialized.IsAccepted);
+ Assert.Null(deserialized.Content);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationCapabilityTests.cs
new file mode 100644
index 0000000..37234f7
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationCapabilityTests.cs
@@ -0,0 +1,38 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ElicitationCapabilityTests
+{
+ [Fact]
+ public static void ElicitationCapability_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ElicitationCapability
+ {
+ Form = new FormElicitationCapability(),
+ Url = new UrlElicitationCapability()
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Form);
+ Assert.NotNull(deserialized.Url);
+ }
+
+ [Fact]
+ public static void ElicitationCapability_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ElicitationCapability();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ // The custom converter defaults Form to non-null when both Form and Url are null for backward compatibility
+ Assert.NotNull(deserialized.Form);
+ Assert.Null(deserialized.Url);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationCompleteNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationCompleteNotificationParamsTests.cs
new file mode 100644
index 0000000..1f9097a
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationCompleteNotificationParamsTests.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ElicitationCompleteNotificationParamsTests
+{
+ [Fact]
+ public static void ElicitationCompleteNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ElicitationCompleteNotificationParams
+ {
+ ElicitationId = "elicit-abc-123",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("elicit-abc-123", deserialized.ElicitationId);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ElicitationCompleteNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ElicitationCompleteNotificationParams
+ {
+ ElicitationId = "elicit-min"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("elicit-min", deserialized.ElicitationId);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/EmptyResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/EmptyResultTests.cs
new file mode 100644
index 0000000..2f3ce58
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/EmptyResultTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class EmptyResultTests
+{
+ [Fact]
+ public static void EmptyResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new EmptyResult
+ {
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void EmptyResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new EmptyResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetPromptRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetPromptRequestParamsTests.cs
new file mode 100644
index 0000000..5a88253
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/GetPromptRequestParamsTests.cs
@@ -0,0 +1,49 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class GetPromptRequestParamsTests
+{
+ [Fact]
+ public static void GetPromptRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new GetPromptRequestParams
+ {
+ Name = "code_review",
+ Arguments = new Dictionary
+ {
+ ["language"] = JsonDocument.Parse("\"csharp\"").RootElement.Clone()
+ },
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("code_review", deserialized.Name);
+ Assert.NotNull(deserialized.Arguments);
+ Assert.Equal("csharp", deserialized.Arguments["language"].GetString());
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void GetPromptRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new GetPromptRequestParams
+ {
+ Name = "simple_prompt"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("simple_prompt", deserialized.Name);
+ Assert.Null(deserialized.Arguments);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetPromptResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetPromptResultTests.cs
new file mode 100644
index 0000000..7141d03
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/GetPromptResultTests.cs
@@ -0,0 +1,60 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class GetPromptResultTests
+{
+ [Fact]
+ public static void GetPromptResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new GetPromptResult
+ {
+ Description = "A code review prompt",
+ Messages =
+ [
+ new PromptMessage
+ {
+ Role = Role.User,
+ Content = new TextContentBlock { Text = "Review this code" }
+ },
+ new PromptMessage
+ {
+ Role = Role.Assistant,
+ Content = new TextContentBlock { Text = "I'll review it" }
+ }
+ ],
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("A code review prompt", deserialized.Description);
+ Assert.Equal(2, deserialized.Messages.Count);
+ Assert.Equal(Role.User, deserialized.Messages[0].Role);
+ var textBlock0 = Assert.IsType(deserialized.Messages[0].Content);
+ Assert.Equal("Review this code", textBlock0.Text);
+ Assert.Equal(Role.Assistant, deserialized.Messages[1].Role);
+ var textBlock1 = Assert.IsType(deserialized.Messages[1].Content);
+ Assert.Equal("I'll review it", textBlock1.Text);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void GetPromptResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new GetPromptResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Description);
+ Assert.Empty(deserialized.Messages);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs
new file mode 100644
index 0000000..47f4272
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs
@@ -0,0 +1,25 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class GetTaskPayloadRequestParamsTests
+{
+ [Fact]
+ public static void GetTaskPayloadRequestParams_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new GetTaskPayloadRequestParams
+ {
+ TaskId = "payload-task-999"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.TaskId, deserialized.TaskId);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs
new file mode 100644
index 0000000..9b3e7b1
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs
@@ -0,0 +1,25 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class GetTaskRequestParamsTests
+{
+ [Fact]
+ public static void GetTaskRequestParams_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new GetTaskRequestParams
+ {
+ TaskId = "get-task-123"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.TaskId, deserialized.TaskId);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs
new file mode 100644
index 0000000..ece5868
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs
@@ -0,0 +1,37 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class GetTaskResultTests
+{
+ [Fact]
+ public static void GetTaskResult_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new GetTaskResult
+ {
+ TaskId = "result-123",
+ Status = McpTaskStatus.Completed,
+ StatusMessage = "Done",
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ TimeToLive = TimeSpan.FromHours(1),
+ PollInterval = TimeSpan.FromSeconds(1)
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.TaskId, deserialized.TaskId);
+ Assert.Equal(original.Status, deserialized.Status);
+ Assert.Equal(original.StatusMessage, deserialized.StatusMessage);
+ Assert.Equal(original.CreatedAt, deserialized.CreatedAt);
+ Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt);
+ Assert.Equal(original.TimeToLive, deserialized.TimeToLive);
+ Assert.Equal(original.PollInterval, deserialized.PollInterval);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/InitializeRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/InitializeRequestParamsTests.cs
new file mode 100644
index 0000000..156c68f
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/InitializeRequestParamsTests.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class InitializeRequestParamsTests
+{
+ [Fact]
+ public static void InitializeRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new InitializeRequestParams
+ {
+ ProtocolVersion = "2024-11-05",
+ Capabilities = new ClientCapabilities
+ {
+ Roots = new RootsCapability { ListChanged = true },
+ Sampling = new SamplingCapability()
+ },
+ ClientInfo = new Implementation
+ {
+ Name = "test-client",
+ Version = "1.0.0"
+ },
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("2024-11-05", deserialized.ProtocolVersion);
+ Assert.NotNull(deserialized.Capabilities);
+ Assert.NotNull(deserialized.Capabilities.Roots);
+ Assert.True(deserialized.Capabilities.Roots.ListChanged);
+ Assert.NotNull(deserialized.Capabilities.Sampling);
+ Assert.Equal("test-client", deserialized.ClientInfo.Name);
+ Assert.Equal("1.0.0", deserialized.ClientInfo.Version);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/InitializeResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/InitializeResultTests.cs
new file mode 100644
index 0000000..afb2b60
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/InitializeResultTests.cs
@@ -0,0 +1,69 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class InitializeResultTests
+{
+ [Fact]
+ public static void InitializeResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new InitializeResult
+ {
+ ProtocolVersion = "2024-11-05",
+ Capabilities = new ServerCapabilities
+ {
+ Tools = new ToolsCapability { ListChanged = true },
+ Logging = new LoggingCapability()
+ },
+ ServerInfo = new Implementation
+ {
+ Name = "test-server",
+ Version = "2.0.0"
+ },
+ Instructions = "Use this server for testing",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("2024-11-05", deserialized.ProtocolVersion);
+ Assert.NotNull(deserialized.Capabilities);
+ Assert.NotNull(deserialized.Capabilities.Tools);
+ Assert.True(deserialized.Capabilities.Tools.ListChanged);
+ Assert.NotNull(deserialized.Capabilities.Logging);
+ Assert.Equal("test-server", deserialized.ServerInfo.Name);
+ Assert.Equal("2.0.0", deserialized.ServerInfo.Version);
+ Assert.Equal("Use this server for testing", deserialized.Instructions);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void InitializeResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new InitializeResult
+ {
+ ProtocolVersion = "2024-11-05",
+ Capabilities = new ServerCapabilities(),
+ ServerInfo = new Implementation
+ {
+ Name = "minimal-server",
+ Version = "1.0.0"
+ }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("2024-11-05", deserialized.ProtocolVersion);
+ Assert.NotNull(deserialized.Capabilities);
+ Assert.Equal("minimal-server", deserialized.ServerInfo.Name);
+ Assert.Null(deserialized.Instructions);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/InitializedNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/InitializedNotificationParamsTests.cs
new file mode 100644
index 0000000..1c99aa6
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/InitializedNotificationParamsTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class InitializedNotificationParamsTests
+{
+ [Fact]
+ public static void InitializedNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new InitializedNotificationParams
+ {
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void InitializedNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new InitializedNotificationParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcErrorTests.cs b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcErrorTests.cs
new file mode 100644
index 0000000..59bacb0
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcErrorTests.cs
@@ -0,0 +1,55 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class JsonRpcErrorTests
+{
+ [Fact]
+ public static void JsonRpcError_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new JsonRpcError
+ {
+ Id = new RequestId(1),
+ Error = new JsonRpcErrorDetail
+ {
+ Code = -32600,
+ Message = "Invalid Request",
+ Data = "Additional error context"
+ }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ var error = Assert.IsType(deserialized);
+ Assert.Equal(original.Id, error.Id);
+ Assert.Equal(-32600, error.Error.Code);
+ Assert.Equal("Invalid Request", error.Error.Message);
+ Assert.NotNull(error.Error.Data);
+ }
+
+ [Fact]
+ public static void JsonRpcError_SerializationRoundTrip_WithoutOptionalData()
+ {
+ var original = new JsonRpcError
+ {
+ Id = new RequestId("err-42"),
+ Error = new JsonRpcErrorDetail
+ {
+ Code = -32601,
+ Message = "Method not found"
+ }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ var error = Assert.IsType(deserialized);
+ Assert.Equal(original.Id, error.Id);
+ Assert.Equal(-32601, error.Error.Code);
+ Assert.Equal("Method not found", error.Error.Message);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListPromptsRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListPromptsRequestParamsTests.cs
new file mode 100644
index 0000000..dfb12af
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListPromptsRequestParamsTests.cs
@@ -0,0 +1,39 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListPromptsRequestParamsTests
+{
+ [Fact]
+ public static void ListPromptsRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListPromptsRequestParams
+ {
+ Cursor = "page-2-cursor",
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("page-2-cursor", deserialized.Cursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void ListPromptsRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListPromptsRequestParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Cursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListPromptsResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListPromptsResultTests.cs
new file mode 100644
index 0000000..c6e1c0c
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListPromptsResultTests.cs
@@ -0,0 +1,58 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListPromptsResultTests
+{
+ [Fact]
+ public static void ListPromptsResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListPromptsResult
+ {
+ Prompts =
+ [
+ new Prompt
+ {
+ Name = "code_review",
+ Title = "Code Review",
+ Description = "Reviews code changes"
+ },
+ new Prompt
+ {
+ Name = "summarize",
+ Description = "Summarizes text"
+ }
+ ],
+ NextCursor = "page-2-token",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(2, deserialized.Prompts.Count);
+ Assert.Equal("code_review", deserialized.Prompts[0].Name);
+ Assert.Equal("Code Review", deserialized.Prompts[0].Title);
+ Assert.Equal("summarize", deserialized.Prompts[1].Name);
+ Assert.Equal("page-2-token", deserialized.NextCursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ListPromptsResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListPromptsResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Empty(deserialized.Prompts);
+ Assert.Null(deserialized.NextCursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesRequestParamsTests.cs
new file mode 100644
index 0000000..b2ef2b3
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesRequestParamsTests.cs
@@ -0,0 +1,39 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListResourceTemplatesRequestParamsTests
+{
+ [Fact]
+ public static void ListResourceTemplatesRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListResourceTemplatesRequestParams
+ {
+ Cursor = "cursor-xyz",
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("cursor-xyz", deserialized.Cursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void ListResourceTemplatesRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListResourceTemplatesRequestParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Cursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesResultTests.cs
new file mode 100644
index 0000000..15c0d7d
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListResourceTemplatesResultTests.cs
@@ -0,0 +1,55 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListResourceTemplatesResultTests
+{
+ [Fact]
+ public static void ListResourceTemplatesResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListResourceTemplatesResult
+ {
+ ResourceTemplates =
+ [
+ new ResourceTemplate
+ {
+ Name = "document",
+ UriTemplate = "file:///{path}",
+ Description = "A document",
+ MimeType = "text/plain"
+ }
+ ],
+ NextCursor = "cursor-abc",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Single(deserialized.ResourceTemplates);
+ Assert.Equal("document", deserialized.ResourceTemplates[0].Name);
+ Assert.Equal("file:///{path}", deserialized.ResourceTemplates[0].UriTemplate);
+ Assert.Equal("A document", deserialized.ResourceTemplates[0].Description);
+ Assert.Equal("text/plain", deserialized.ResourceTemplates[0].MimeType);
+ Assert.Equal("cursor-abc", deserialized.NextCursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ListResourceTemplatesResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListResourceTemplatesResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Empty(deserialized.ResourceTemplates);
+ Assert.Null(deserialized.NextCursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListResourcesRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListResourcesRequestParamsTests.cs
new file mode 100644
index 0000000..3aa36e8
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListResourcesRequestParamsTests.cs
@@ -0,0 +1,39 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListResourcesRequestParamsTests
+{
+ [Fact]
+ public static void ListResourcesRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListResourcesRequestParams
+ {
+ Cursor = "cursor-abc",
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("cursor-abc", deserialized.Cursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void ListResourcesRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListResourcesRequestParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Cursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListResourcesResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListResourcesResultTests.cs
new file mode 100644
index 0000000..decb734
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListResourcesResultTests.cs
@@ -0,0 +1,53 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListResourcesResultTests
+{
+ [Fact]
+ public static void ListResourcesResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListResourcesResult
+ {
+ Resources =
+ [
+ new Resource
+ {
+ Uri = "file:///readme.md",
+ Name = "README",
+ MimeType = "text/markdown"
+ }
+ ],
+ NextCursor = "next-page",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Single(deserialized.Resources);
+ Assert.Equal("file:///readme.md", deserialized.Resources[0].Uri);
+ Assert.Equal("README", deserialized.Resources[0].Name);
+ Assert.Equal("text/markdown", deserialized.Resources[0].MimeType);
+ Assert.Equal("next-page", deserialized.NextCursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ListResourcesResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListResourcesResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Empty(deserialized.Resources);
+ Assert.Null(deserialized.NextCursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListRootsRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListRootsRequestParamsTests.cs
new file mode 100644
index 0000000..c16b48c
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListRootsRequestParamsTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListRootsRequestParamsTests
+{
+ [Fact]
+ public static void ListRootsRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListRootsRequestParams
+ {
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void ListRootsRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListRootsRequestParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListRootsResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListRootsResultTests.cs
new file mode 100644
index 0000000..6bb4f7a
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListRootsResultTests.cs
@@ -0,0 +1,34 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListRootsResultTests
+{
+ [Fact]
+ public static void ListRootsResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListRootsResult
+ {
+ Roots =
+ [
+ new Root { Uri = "file:///home/user/project", Name = "Project" },
+ new Root { Uri = "file:///home/user/docs", Name = "Docs" }
+ ],
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(2, deserialized.Roots.Count);
+ Assert.Equal("file:///home/user/project", deserialized.Roots[0].Uri);
+ Assert.Equal("Project", deserialized.Roots[0].Name);
+ Assert.Equal("file:///home/user/docs", deserialized.Roots[1].Uri);
+ Assert.Equal("Docs", deserialized.Roots[1].Name);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs
new file mode 100644
index 0000000..3e90227
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs
@@ -0,0 +1,25 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListTasksRequestParamsTests
+{
+ [Fact]
+ public static void ListTasksRequestParams_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new ListTasksRequestParams
+ {
+ Cursor = "cursor-abc123"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.Cursor, deserialized.Cursor);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs
new file mode 100644
index 0000000..4305d0e
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs
@@ -0,0 +1,46 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListTasksResultTests
+{
+ [Fact]
+ public static void ListTasksResult_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new ListTasksResult
+ {
+ Tasks =
+ [
+ new McpTask
+ {
+ TaskId = "task-1",
+ Status = McpTaskStatus.Working,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow
+ },
+ new McpTask
+ {
+ TaskId = "task-2",
+ Status = McpTaskStatus.Completed,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow
+ }
+ ],
+ NextCursor = "next-page-token"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Tasks);
+ Assert.Equal(2, deserialized.Tasks.Length);
+ Assert.Equal(original.Tasks[0].TaskId, deserialized.Tasks[0].TaskId);
+ Assert.Equal(original.Tasks[1].TaskId, deserialized.Tasks[1].TaskId);
+ Assert.Equal(original.NextCursor, deserialized.NextCursor);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListToolsRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListToolsRequestParamsTests.cs
new file mode 100644
index 0000000..aa1d27d
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListToolsRequestParamsTests.cs
@@ -0,0 +1,39 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListToolsRequestParamsTests
+{
+ [Fact]
+ public static void ListToolsRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListToolsRequestParams
+ {
+ Cursor = "tools-cursor",
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("tools-cursor", deserialized.Cursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void ListToolsRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListToolsRequestParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Cursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListToolsResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListToolsResultTests.cs
new file mode 100644
index 0000000..209e571
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ListToolsResultTests.cs
@@ -0,0 +1,50 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ListToolsResultTests
+{
+ [Fact]
+ public static void ListToolsResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ListToolsResult
+ {
+ Tools =
+ [
+ new Tool { Name = "get_weather", Description = "Gets weather" },
+ new Tool { Name = "search", Title = "Search Tool" }
+ ],
+ NextCursor = "next-token",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(2, deserialized.Tools.Count);
+ Assert.Equal("get_weather", deserialized.Tools[0].Name);
+ Assert.Equal("Gets weather", deserialized.Tools[0].Description);
+ Assert.Equal("search", deserialized.Tools[1].Name);
+ Assert.Equal("Search Tool", deserialized.Tools[1].Title);
+ Assert.Equal("next-token", deserialized.NextCursor);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ListToolsResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ListToolsResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Empty(deserialized.Tools);
+ Assert.Null(deserialized.NextCursor);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/LoggingMessageNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/LoggingMessageNotificationParamsTests.cs
new file mode 100644
index 0000000..449db33
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/LoggingMessageNotificationParamsTests.cs
@@ -0,0 +1,49 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class LoggingMessageNotificationParamsTests
+{
+ [Fact]
+ public static void LoggingMessageNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new LoggingMessageNotificationParams
+ {
+ Level = LoggingLevel.Warning,
+ Logger = "MyApp.Services",
+ Data = JsonDocument.Parse("\"Something went wrong\"").RootElement.Clone(),
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(LoggingLevel.Warning, deserialized.Level);
+ Assert.Equal("MyApp.Services", deserialized.Logger);
+ Assert.NotNull(deserialized.Data);
+ Assert.Equal("Something went wrong", deserialized.Data.Value.GetString());
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void LoggingMessageNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new LoggingMessageNotificationParams
+ {
+ Level = LoggingLevel.Error
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(LoggingLevel.Error, deserialized.Level);
+ Assert.Null(deserialized.Logger);
+ Assert.Null(deserialized.Data);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs
new file mode 100644
index 0000000..82f33fb
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs
@@ -0,0 +1,53 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class McpTaskMetadataTests
+{
+ [Fact]
+ public static void McpTaskMetadata_SerializationRoundTrip_WithTimeToLive()
+ {
+ // Arrange
+ var original = new McpTaskMetadata
+ {
+ TimeToLive = TimeSpan.FromHours(2)
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.TimeToLive, deserialized.TimeToLive);
+ }
+
+ [Fact]
+ public static void McpTaskMetadata_SerializationRoundTrip_WithNullTimeToLive()
+ {
+ // Arrange
+ var original = new McpTaskMetadata();
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.TimeToLive);
+ }
+
+ [Fact]
+ public static void McpTaskMetadata_HasCorrectJsonPropertyNames()
+ {
+ var metadata = new McpTaskMetadata
+ {
+ TimeToLive = TimeSpan.FromMinutes(15)
+ };
+
+ string json = JsonSerializer.Serialize(metadata, McpJsonUtilities.DefaultOptions);
+
+ Assert.Contains("\"ttl\":", json);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs
new file mode 100644
index 0000000..bf3cbbb
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs
@@ -0,0 +1,37 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class McpTaskStatusNotificationParamsTests
+{
+ [Fact]
+ public static void McpTaskStatusNotificationParams_SerializationRoundTrip()
+ {
+ // Arrange
+ var original = new McpTaskStatusNotificationParams
+ {
+ TaskId = "notification-task",
+ Status = McpTaskStatus.Completed,
+ StatusMessage = "Task completed successfully",
+ CreatedAt = new DateTimeOffset(2025, 12, 9, 10, 0, 0, TimeSpan.Zero),
+ LastUpdatedAt = new DateTimeOffset(2025, 12, 9, 10, 30, 0, TimeSpan.Zero),
+ TimeToLive = TimeSpan.FromHours(1),
+ PollInterval = TimeSpan.FromSeconds(2)
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.TaskId, deserialized.TaskId);
+ Assert.Equal(original.Status, deserialized.Status);
+ Assert.Equal(original.StatusMessage, deserialized.StatusMessage);
+ Assert.Equal(original.CreatedAt, deserialized.CreatedAt);
+ Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt);
+ Assert.Equal(original.TimeToLive, deserialized.TimeToLive);
+ Assert.Equal(original.PollInterval, deserialized.PollInterval);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs
index 070a247..7919e40 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs
@@ -157,432 +157,4 @@ public static class McpTaskTests
Assert.Contains($"\"status\":\"{expectedJson}\"", json);
}
}
-
- [Fact]
- public static void McpTaskMetadata_SerializationRoundTrip_WithTimeToLive()
- {
- // Arrange
- var original = new McpTaskMetadata
- {
- TimeToLive = TimeSpan.FromHours(2)
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.TimeToLive, deserialized.TimeToLive);
- }
-
- [Fact]
- public static void McpTaskMetadata_SerializationRoundTrip_WithNullTimeToLive()
- {
- // Arrange
- var original = new McpTaskMetadata();
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Null(deserialized.TimeToLive);
- }
-
- [Fact]
- public static void McpTaskMetadata_HasCorrectJsonPropertyNames()
- {
- var metadata = new McpTaskMetadata
- {
- TimeToLive = TimeSpan.FromMinutes(15)
- };
-
- string json = JsonSerializer.Serialize(metadata, McpJsonUtilities.DefaultOptions);
-
- Assert.Contains("\"ttl\":", json);
- }
-
- [Fact]
- public static void McpTaskStatusNotificationParams_SerializationRoundTrip()
- {
- // Arrange
- var original = new McpTaskStatusNotificationParams
- {
- TaskId = "notification-task",
- Status = McpTaskStatus.Completed,
- StatusMessage = "Task completed successfully",
- CreatedAt = new DateTimeOffset(2025, 12, 9, 10, 0, 0, TimeSpan.Zero),
- LastUpdatedAt = new DateTimeOffset(2025, 12, 9, 10, 30, 0, TimeSpan.Zero),
- TimeToLive = TimeSpan.FromHours(1),
- PollInterval = TimeSpan.FromSeconds(2)
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.TaskId, deserialized.TaskId);
- Assert.Equal(original.Status, deserialized.Status);
- Assert.Equal(original.StatusMessage, deserialized.StatusMessage);
- Assert.Equal(original.CreatedAt, deserialized.CreatedAt);
- Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt);
- Assert.Equal(original.TimeToLive, deserialized.TimeToLive);
- Assert.Equal(original.PollInterval, deserialized.PollInterval);
- }
-
- [Fact]
- public static void GetTaskRequestParams_SerializationRoundTrip()
- {
- // Arrange
- var original = new GetTaskRequestParams
- {
- TaskId = "get-task-123"
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.TaskId, deserialized.TaskId);
- }
-
- [Fact]
- public static void GetTaskResult_SerializationRoundTrip()
- {
- // Arrange
- var original = new GetTaskResult
- {
- TaskId = "result-123",
- Status = McpTaskStatus.Completed,
- StatusMessage = "Done",
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- TimeToLive = TimeSpan.FromHours(1),
- PollInterval = TimeSpan.FromSeconds(1)
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.TaskId, deserialized.TaskId);
- Assert.Equal(original.Status, deserialized.Status);
- Assert.Equal(original.StatusMessage, deserialized.StatusMessage);
- Assert.Equal(original.CreatedAt, deserialized.CreatedAt);
- Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt);
- Assert.Equal(original.TimeToLive, deserialized.TimeToLive);
- Assert.Equal(original.PollInterval, deserialized.PollInterval);
- }
-
- [Fact]
- public static void CancelMcpTaskRequestParams_SerializationRoundTrip()
- {
- // Arrange
- var original = new CancelMcpTaskRequestParams
- {
- TaskId = "cancel-task-456"
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.TaskId, deserialized.TaskId);
- }
-
- [Fact]
- public static void CancelMcpTaskResult_SerializationRoundTrip()
- {
- // Arrange
- var original = new CancelMcpTaskResult
- {
- TaskId = "cancelled-789",
- Status = McpTaskStatus.Cancelled,
- StatusMessage = "Cancelled by user",
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- TimeToLive = null,
- PollInterval = null
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.TaskId, deserialized.TaskId);
- Assert.Equal(original.Status, deserialized.Status);
- Assert.Equal(original.StatusMessage, deserialized.StatusMessage);
- }
-
- [Fact]
- public static void ListTasksRequestParams_SerializationRoundTrip()
- {
- // Arrange
- var original = new ListTasksRequestParams
- {
- Cursor = "cursor-abc123"
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.Cursor, deserialized.Cursor);
- }
-
- [Fact]
- public static void ListTasksResult_SerializationRoundTrip()
- {
- // Arrange
- var original = new ListTasksResult
- {
- Tasks =
- [
- new McpTask
- {
- TaskId = "task-1",
- Status = McpTaskStatus.Working,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow
- },
- new McpTask
- {
- TaskId = "task-2",
- Status = McpTaskStatus.Completed,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow
- }
- ],
- NextCursor = "next-page-token"
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.NotNull(deserialized.Tasks);
- Assert.Equal(2, deserialized.Tasks.Length);
- Assert.Equal(original.Tasks[0].TaskId, deserialized.Tasks[0].TaskId);
- Assert.Equal(original.Tasks[1].TaskId, deserialized.Tasks[1].TaskId);
- Assert.Equal(original.NextCursor, deserialized.NextCursor);
- }
-
- [Fact]
- public static void GetTaskPayloadRequestParams_SerializationRoundTrip()
- {
- // Arrange
- var original = new GetTaskPayloadRequestParams
- {
- TaskId = "payload-task-999"
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(original.TaskId, deserialized.TaskId);
- }
-
- [Fact]
- public static void McpTasksCapability_SerializationRoundTrip_WithAllProperties()
- {
- // Arrange
- var original = new McpTasksCapability
- {
- List = new ListMcpTasksCapability(),
- Cancel = new CancelMcpTasksCapability(),
- Requests = new RequestMcpTasksCapability
- {
- Tools = new ToolsMcpTasksCapability
- {
- Call = new CallToolMcpTasksCapability()
- },
- Sampling = new SamplingMcpTasksCapability
- {
- CreateMessage = new CreateMessageMcpTasksCapability()
- },
- Elicitation = new ElicitationMcpTasksCapability
- {
- Create = new CreateElicitationMcpTasksCapability()
- }
- }
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.NotNull(deserialized.List);
- Assert.NotNull(deserialized.Cancel);
- Assert.NotNull(deserialized.Requests);
- Assert.NotNull(deserialized.Requests.Tools);
- Assert.NotNull(deserialized.Requests.Tools.Call);
- Assert.NotNull(deserialized.Requests.Sampling);
- Assert.NotNull(deserialized.Requests.Sampling.CreateMessage);
- Assert.NotNull(deserialized.Requests.Elicitation);
- Assert.NotNull(deserialized.Requests.Elicitation.Create);
- }
-
- [Fact]
- public static void McpTasksCapability_SerializationRoundTrip_WithMinimalProperties()
- {
- // Arrange
- var original = new McpTasksCapability();
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Null(deserialized.List);
- Assert.Null(deserialized.Cancel);
- Assert.Null(deserialized.Requests);
- }
-
- [Fact]
- public static void McpTasksCapability_HasCorrectJsonPropertyNames()
- {
- var capability = new McpTasksCapability
- {
- List = new ListMcpTasksCapability(),
- Cancel = new CancelMcpTasksCapability(),
- Requests = new RequestMcpTasksCapability
- {
- Tools = new ToolsMcpTasksCapability
- {
- Call = new CallToolMcpTasksCapability()
- }
- }
- };
-
- string json = JsonSerializer.Serialize(capability, McpJsonUtilities.DefaultOptions);
-
- Assert.Contains("\"list\":", json);
- Assert.Contains("\"cancel\":", json);
- Assert.Contains("\"requests\":", json);
- Assert.Contains("\"tools\":", json);
- Assert.Contains("\"call\":", json);
- }
-
- [Fact]
- public static void RequestMcpTasksCapability_SerializationRoundTrip_ToolsOnly()
- {
- // Arrange
- var original = new RequestMcpTasksCapability
- {
- Tools = new ToolsMcpTasksCapability
- {
- Call = new CallToolMcpTasksCapability()
- }
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.NotNull(deserialized.Tools);
- Assert.NotNull(deserialized.Tools.Call);
- Assert.Null(deserialized.Sampling);
- Assert.Null(deserialized.Elicitation);
- }
-
- [Fact]
- public static void RequestMcpTasksCapability_SerializationRoundTrip_SamplingOnly()
- {
- // Arrange
- var original = new RequestMcpTasksCapability
- {
- Sampling = new SamplingMcpTasksCapability
- {
- CreateMessage = new CreateMessageMcpTasksCapability()
- }
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Null(deserialized.Tools);
- Assert.NotNull(deserialized.Sampling);
- Assert.NotNull(deserialized.Sampling.CreateMessage);
- Assert.Null(deserialized.Elicitation);
- }
-
- [Fact]
- public static void RequestMcpTasksCapability_SerializationRoundTrip_ElicitationOnly()
- {
- // Arrange
- var original = new RequestMcpTasksCapability
- {
- Elicitation = new ElicitationMcpTasksCapability
- {
- Create = new CreateElicitationMcpTasksCapability()
- }
- };
-
- // Act
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Null(deserialized.Tools);
- Assert.Null(deserialized.Sampling);
- Assert.NotNull(deserialized.Elicitation);
- Assert.NotNull(deserialized.Elicitation.Create);
- }
-
- [Fact]
- public static void RequestMcpTasksCapability_HasCorrectJsonPropertyNames()
- {
- var capability = new RequestMcpTasksCapability
- {
- Tools = new ToolsMcpTasksCapability
- {
- Call = new CallToolMcpTasksCapability()
- },
- Sampling = new SamplingMcpTasksCapability
- {
- CreateMessage = new CreateMessageMcpTasksCapability()
- },
- Elicitation = new ElicitationMcpTasksCapability
- {
- Create = new CreateElicitationMcpTasksCapability()
- }
- };
-
- string json = JsonSerializer.Serialize(capability, McpJsonUtilities.DefaultOptions);
-
- Assert.Contains("\"tools\":", json);
- Assert.Contains("\"sampling\":", json);
- Assert.Contains("\"elicitation\":", json);
- Assert.Contains("\"call\":", json);
- Assert.Contains("\"createMessage\":", json);
- Assert.Contains("\"create\":", json);
- }
}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs
new file mode 100644
index 0000000..4e8caa7
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs
@@ -0,0 +1,91 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class McpTasksCapabilityTests
+{
+ [Fact]
+ public static void McpTasksCapability_SerializationRoundTrip_WithAllProperties()
+ {
+ // Arrange
+ var original = new McpTasksCapability
+ {
+ List = new ListMcpTasksCapability(),
+ Cancel = new CancelMcpTasksCapability(),
+ Requests = new RequestMcpTasksCapability
+ {
+ Tools = new ToolsMcpTasksCapability
+ {
+ Call = new CallToolMcpTasksCapability()
+ },
+ Sampling = new SamplingMcpTasksCapability
+ {
+ CreateMessage = new CreateMessageMcpTasksCapability()
+ },
+ Elicitation = new ElicitationMcpTasksCapability
+ {
+ Create = new CreateElicitationMcpTasksCapability()
+ }
+ }
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.List);
+ Assert.NotNull(deserialized.Cancel);
+ Assert.NotNull(deserialized.Requests);
+ Assert.NotNull(deserialized.Requests.Tools);
+ Assert.NotNull(deserialized.Requests.Tools.Call);
+ Assert.NotNull(deserialized.Requests.Sampling);
+ Assert.NotNull(deserialized.Requests.Sampling.CreateMessage);
+ Assert.NotNull(deserialized.Requests.Elicitation);
+ Assert.NotNull(deserialized.Requests.Elicitation.Create);
+ }
+
+ [Fact]
+ public static void McpTasksCapability_SerializationRoundTrip_WithMinimalProperties()
+ {
+ // Arrange
+ var original = new McpTasksCapability();
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.List);
+ Assert.Null(deserialized.Cancel);
+ Assert.Null(deserialized.Requests);
+ }
+
+ [Fact]
+ public static void McpTasksCapability_HasCorrectJsonPropertyNames()
+ {
+ var capability = new McpTasksCapability
+ {
+ List = new ListMcpTasksCapability(),
+ Cancel = new CancelMcpTasksCapability(),
+ Requests = new RequestMcpTasksCapability
+ {
+ Tools = new ToolsMcpTasksCapability
+ {
+ Call = new CallToolMcpTasksCapability()
+ }
+ }
+ };
+
+ string json = JsonSerializer.Serialize(capability, McpJsonUtilities.DefaultOptions);
+
+ Assert.Contains("\"list\":", json);
+ Assert.Contains("\"cancel\":", json);
+ Assert.Contains("\"requests\":", json);
+ Assert.Contains("\"tools\":", json);
+ Assert.Contains("\"call\":", json);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ModelPreferencesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ModelPreferencesTests.cs
new file mode 100644
index 0000000..5407030
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ModelPreferencesTests.cs
@@ -0,0 +1,74 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ModelPreferencesTests
+{
+ [Fact]
+ public static void ModelPreferences_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ModelPreferences
+ {
+ CostPriority = 0.3f,
+ SpeedPriority = 0.7f,
+ IntelligencePriority = 0.9f,
+ Hints =
+ [
+ new ModelHint { Name = "gpt-4" },
+ new ModelHint { Name = "claude-3" }
+ ]
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(0.3f, deserialized.CostPriority);
+ Assert.Equal(0.7f, deserialized.SpeedPriority);
+ Assert.Equal(0.9f, deserialized.IntelligencePriority);
+ Assert.NotNull(deserialized.Hints);
+ Assert.Equal(2, deserialized.Hints.Count);
+ Assert.Equal("gpt-4", deserialized.Hints[0].Name);
+ Assert.Equal("claude-3", deserialized.Hints[1].Name);
+ }
+
+ [Fact]
+ public static void ModelPreferences_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ModelPreferences();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.CostPriority);
+ Assert.Null(deserialized.SpeedPriority);
+ Assert.Null(deserialized.IntelligencePriority);
+ Assert.Null(deserialized.Hints);
+ }
+
+ [Fact]
+ public static void ModelHint_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ModelHint { Name = "gpt-4o" };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("gpt-4o", deserialized.Name);
+ }
+
+ [Fact]
+ public static void ModelHint_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ModelHint();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Name);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/PingRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PingRequestParamsTests.cs
new file mode 100644
index 0000000..df03bab
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/PingRequestParamsTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class PingRequestParamsTests
+{
+ [Fact]
+ public static void PingRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new PingRequestParams
+ {
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void PingRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new PingRequestParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/PingResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PingResultTests.cs
new file mode 100644
index 0000000..373ef78
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/PingResultTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class PingResultTests
+{
+ [Fact]
+ public static void PingResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new PingResult
+ {
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void PingResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new PingResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/PromptArgumentTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PromptArgumentTests.cs
new file mode 100644
index 0000000..4d953a9
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/PromptArgumentTests.cs
@@ -0,0 +1,46 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class PromptArgumentTests
+{
+ [Fact]
+ public static void PromptArgument_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new PromptArgument
+ {
+ Name = "topic",
+ Title = "Topic",
+ Description = "The topic to discuss",
+ Required = true
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("topic", deserialized.Name);
+ Assert.Equal("Topic", deserialized.Title);
+ Assert.Equal("The topic to discuss", deserialized.Description);
+ Assert.True(deserialized.Required);
+ }
+
+ [Fact]
+ public static void PromptArgument_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new PromptArgument
+ {
+ Name = "input"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("input", deserialized.Name);
+ Assert.Null(deserialized.Title);
+ Assert.Null(deserialized.Description);
+ Assert.Null(deserialized.Required);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/PromptListChangedNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PromptListChangedNotificationParamsTests.cs
new file mode 100644
index 0000000..cfbbd23
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/PromptListChangedNotificationParamsTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class PromptListChangedNotificationParamsTests
+{
+ [Fact]
+ public static void PromptListChangedNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new PromptListChangedNotificationParams
+ {
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void PromptListChangedNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new PromptListChangedNotificationParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/PromptMessageTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PromptMessageTests.cs
new file mode 100644
index 0000000..5e4a7f8
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/PromptMessageTests.cs
@@ -0,0 +1,44 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class PromptMessageTests
+{
+ [Fact]
+ public static void PromptMessage_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new PromptMessage
+ {
+ Role = Role.User,
+ Content = new TextContentBlock { Text = "Hello, world!" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(Role.User, deserialized.Role);
+ var textBlock = Assert.IsType(deserialized.Content);
+ Assert.Equal("Hello, world!", textBlock.Text);
+ }
+
+ [Fact]
+ public static void PromptMessage_SerializationRoundTrip_WithImageContent()
+ {
+ var original = new PromptMessage
+ {
+ Role = Role.Assistant,
+ Content = new ImageContentBlock { Data = "base64data", MimeType = "image/png" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(Role.Assistant, deserialized.Role);
+ var imageBlock = Assert.IsType(deserialized.Content);
+ Assert.Equal("base64data", imageBlock.Data);
+ Assert.Equal("image/png", imageBlock.MimeType);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/PromptsCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PromptsCapabilityTests.cs
new file mode 100644
index 0000000..a78a6e5
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/PromptsCapabilityTests.cs
@@ -0,0 +1,34 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class PromptsCapabilityTests
+{
+ [Fact]
+ public static void PromptsCapability_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new PromptsCapability
+ {
+ ListChanged = true
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.True(deserialized.ListChanged);
+ }
+
+ [Fact]
+ public static void PromptsCapability_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new PromptsCapability();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.ListChanged);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ProtocolTypeTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ProtocolTypeTests.cs
index df224de..2256dfb 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/ProtocolTypeTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/ProtocolTypeTests.cs
@@ -5,53 +5,6 @@ namespace ModelContextProtocol.Tests.Protocol;
public static class ProtocolTypeTests
{
- [Fact]
- public static void ToolInputSchema_HasValidDefaultSchema()
- {
- var tool = new Tool();
- JsonElement jsonElement = tool.InputSchema;
-
- Assert.Equal(JsonValueKind.Object, jsonElement.ValueKind);
- Assert.Single(jsonElement.EnumerateObject());
- Assert.True(jsonElement.TryGetProperty("type", out JsonElement typeElement));
- Assert.Equal(JsonValueKind.String, typeElement.ValueKind);
- Assert.Equal("object", typeElement.GetString());
- }
-
- [Theory]
- [InlineData("null")]
- [InlineData("false")]
- [InlineData("true")]
- [InlineData("3.5e3")]
- [InlineData("[]")]
- [InlineData("{}")]
- [InlineData("""{"properties":{}}""")]
- [InlineData("""{"type":"number"}""")]
- [InlineData("""{"type":"array"}""")]
- [InlineData("""{"type":["object"]}""")]
- public static void ToolInputSchema_RejectsInvalidSchemaDocuments(string invalidSchema)
- {
- using var document = JsonDocument.Parse(invalidSchema);
- var tool = new Tool();
-
- Assert.Throws(() => tool.InputSchema = document.RootElement);
- }
-
- [Theory]
- [InlineData("""{"type":"object"}""")]
- [InlineData("""{"type":"object", "properties": {}, "required" : [] }""")]
- [InlineData("""{"type":"object", "title": "MyAwesomeTool", "description": "It's awesome!", "properties": {}, "required" : ["NotAParam"] }""")]
- public static void ToolInputSchema_AcceptsValidSchemaDocuments(string validSchema)
- {
- using var document = JsonDocument.Parse(validSchema);
- Tool tool = new()
- {
- InputSchema = document.RootElement
- };
-
- Assert.True(JsonElement.DeepEquals(document.RootElement, tool.InputSchema));
- }
-
[Theory]
[InlineData(Role.User, "\"user\"")]
[InlineData(Role.Assistant, "\"assistant\"")]
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ReadResourceRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ReadResourceRequestParamsTests.cs
new file mode 100644
index 0000000..83346b3
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ReadResourceRequestParamsTests.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ReadResourceRequestParamsTests
+{
+ [Fact]
+ public static void ReadResourceRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ReadResourceRequestParams
+ {
+ Uri = "file:///home/user/document.txt",
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///home/user/document.txt", deserialized.Uri);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void ReadResourceRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ReadResourceRequestParams
+ {
+ Uri = "file:///readme.md"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///readme.md", deserialized.Uri);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ReadResourceResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ReadResourceResultTests.cs
new file mode 100644
index 0000000..9607a75
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ReadResourceResultTests.cs
@@ -0,0 +1,64 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ReadResourceResultTests
+{
+ [Fact]
+ public static void ReadResourceResult_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ReadResourceResult
+ {
+ Contents =
+ [
+ new TextResourceContents
+ {
+ Uri = "file:///readme.md",
+ MimeType = "text/markdown",
+ Text = "# Hello"
+ },
+ new BlobResourceContents
+ {
+ Uri = "file:///image.png",
+ MimeType = "image/png",
+ Blob = "base64data"
+ }
+ ],
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(2, deserialized.Contents.Count);
+
+ var textContent = Assert.IsType(deserialized.Contents[0]);
+ Assert.Equal("file:///readme.md", textContent.Uri);
+ Assert.Equal("text/markdown", textContent.MimeType);
+ Assert.Equal("# Hello", textContent.Text);
+
+ var blobContent = Assert.IsType(deserialized.Contents[1]);
+ Assert.Equal("file:///image.png", blobContent.Uri);
+ Assert.Equal("image/png", blobContent.MimeType);
+ Assert.Equal("base64data", blobContent.Blob);
+
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ReadResourceResult_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ReadResourceResult();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Empty(deserialized.Contents);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs
new file mode 100644
index 0000000..8bfcb3b
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs
@@ -0,0 +1,108 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class RequestMcpTasksCapabilityTests
+{
+ [Fact]
+ public static void RequestMcpTasksCapability_SerializationRoundTrip_ToolsOnly()
+ {
+ // Arrange
+ var original = new RequestMcpTasksCapability
+ {
+ Tools = new ToolsMcpTasksCapability
+ {
+ Call = new CallToolMcpTasksCapability()
+ }
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Tools);
+ Assert.NotNull(deserialized.Tools.Call);
+ Assert.Null(deserialized.Sampling);
+ Assert.Null(deserialized.Elicitation);
+ }
+
+ [Fact]
+ public static void RequestMcpTasksCapability_SerializationRoundTrip_SamplingOnly()
+ {
+ // Arrange
+ var original = new RequestMcpTasksCapability
+ {
+ Sampling = new SamplingMcpTasksCapability
+ {
+ CreateMessage = new CreateMessageMcpTasksCapability()
+ }
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Tools);
+ Assert.NotNull(deserialized.Sampling);
+ Assert.NotNull(deserialized.Sampling.CreateMessage);
+ Assert.Null(deserialized.Elicitation);
+ }
+
+ [Fact]
+ public static void RequestMcpTasksCapability_SerializationRoundTrip_ElicitationOnly()
+ {
+ // Arrange
+ var original = new RequestMcpTasksCapability
+ {
+ Elicitation = new ElicitationMcpTasksCapability
+ {
+ Create = new CreateElicitationMcpTasksCapability()
+ }
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Tools);
+ Assert.Null(deserialized.Sampling);
+ Assert.NotNull(deserialized.Elicitation);
+ Assert.NotNull(deserialized.Elicitation.Create);
+ }
+
+ [Fact]
+ public static void RequestMcpTasksCapability_HasCorrectJsonPropertyNames()
+ {
+ var capability = new RequestMcpTasksCapability
+ {
+ Tools = new ToolsMcpTasksCapability
+ {
+ Call = new CallToolMcpTasksCapability()
+ },
+ Sampling = new SamplingMcpTasksCapability
+ {
+ CreateMessage = new CreateMessageMcpTasksCapability()
+ },
+ Elicitation = new ElicitationMcpTasksCapability
+ {
+ Create = new CreateElicitationMcpTasksCapability()
+ }
+ };
+
+ string json = JsonSerializer.Serialize(capability, McpJsonUtilities.DefaultOptions);
+
+ Assert.Contains("\"tools\":", json);
+ Assert.Contains("\"sampling\":", json);
+ Assert.Contains("\"elicitation\":", json);
+ Assert.Contains("\"call\":", json);
+ Assert.Contains("\"createMessage\":", json);
+ Assert.Contains("\"create\":", json);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ResourceListChangedNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ResourceListChangedNotificationParamsTests.cs
new file mode 100644
index 0000000..a695f37
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ResourceListChangedNotificationParamsTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ResourceListChangedNotificationParamsTests
+{
+ [Fact]
+ public static void ResourceListChangedNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ResourceListChangedNotificationParams
+ {
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ResourceListChangedNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ResourceListChangedNotificationParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ResourceTemplateTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ResourceTemplateTests.cs
new file mode 100644
index 0000000..d76309c
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ResourceTemplateTests.cs
@@ -0,0 +1,75 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ResourceTemplateTests
+{
+ [Fact]
+ public static void ResourceTemplate_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ResourceTemplate
+ {
+ Name = "document",
+ Title = "Document Template",
+ UriTemplate = "file:///{path}",
+ Description = "A file document",
+ MimeType = "text/plain",
+ Annotations = new Annotations
+ {
+ Audience = [Role.User],
+ Priority = 0.8f
+ },
+ Icons =
+ [
+ new Icon { Source = "https://example.com/doc.png", MimeType = "image/png" }
+ ],
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("document", deserialized.Name);
+ Assert.Equal("Document Template", deserialized.Title);
+ Assert.Equal("file:///{path}", deserialized.UriTemplate);
+ Assert.Equal("A file document", deserialized.Description);
+ Assert.Equal("text/plain", deserialized.MimeType);
+ Assert.NotNull(deserialized.Annotations);
+ Assert.NotNull(deserialized.Annotations.Audience);
+ Assert.Single(deserialized.Annotations.Audience);
+ Assert.Equal(0.8f, deserialized.Annotations.Priority);
+ Assert.NotNull(deserialized.Icons);
+ Assert.Single(deserialized.Icons);
+ Assert.Equal("https://example.com/doc.png", deserialized.Icons[0].Source);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ Assert.True(deserialized.IsTemplated);
+ }
+
+ [Fact]
+ public static void ResourceTemplate_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ResourceTemplate
+ {
+ Name = "static",
+ UriTemplate = "file:///static"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("static", deserialized.Name);
+ Assert.Equal("file:///static", deserialized.UriTemplate);
+ Assert.Null(deserialized.Title);
+ Assert.Null(deserialized.Description);
+ Assert.Null(deserialized.MimeType);
+ Assert.Null(deserialized.Annotations);
+ Assert.Null(deserialized.Icons);
+ Assert.Null(deserialized.Meta);
+ Assert.False(deserialized.IsTemplated);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ResourceUpdatedNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ResourceUpdatedNotificationParamsTests.cs
new file mode 100644
index 0000000..89ba808
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ResourceUpdatedNotificationParamsTests.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ResourceUpdatedNotificationParamsTests
+{
+ [Fact]
+ public static void ResourceUpdatedNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ResourceUpdatedNotificationParams
+ {
+ Uri = "file:///home/user/data.json",
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///home/user/data.json", deserialized.Uri);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ResourceUpdatedNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ResourceUpdatedNotificationParams
+ {
+ Uri = "file:///resource.txt"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///resource.txt", deserialized.Uri);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ResourcesCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ResourcesCapabilityTests.cs
new file mode 100644
index 0000000..c830a32
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ResourcesCapabilityTests.cs
@@ -0,0 +1,37 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ResourcesCapabilityTests
+{
+ [Fact]
+ public static void ResourcesCapability_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ResourcesCapability
+ {
+ Subscribe = true,
+ ListChanged = true
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.True(deserialized.Subscribe);
+ Assert.True(deserialized.ListChanged);
+ }
+
+ [Fact]
+ public static void ResourcesCapability_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ResourcesCapability();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Subscribe);
+ Assert.Null(deserialized.ListChanged);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/RootTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RootTests.cs
new file mode 100644
index 0000000..8f5bbe6
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/RootTests.cs
@@ -0,0 +1,44 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class RootTests
+{
+ [Fact]
+ public static void Root_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new Root
+ {
+ Uri = "file:///home/user/project",
+ Name = "My Project",
+ Meta = JsonDocument.Parse("""{"custom":"data"}""").RootElement.Clone()
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///home/user/project", deserialized.Uri);
+ Assert.Equal("My Project", deserialized.Name);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("data", deserialized.Meta.Value.GetProperty("custom").GetString());
+ }
+
+ [Fact]
+ public static void Root_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new Root
+ {
+ Uri = "file:///tmp"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///tmp", deserialized.Uri);
+ Assert.Null(deserialized.Name);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/RootsCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RootsCapabilityTests.cs
new file mode 100644
index 0000000..0877a0f
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/RootsCapabilityTests.cs
@@ -0,0 +1,34 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class RootsCapabilityTests
+{
+ [Fact]
+ public static void RootsCapability_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new RootsCapability
+ {
+ ListChanged = true
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.True(deserialized.ListChanged);
+ }
+
+ [Fact]
+ public static void RootsCapability_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new RootsCapability();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.ListChanged);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/RootsListChangedNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RootsListChangedNotificationParamsTests.cs
new file mode 100644
index 0000000..4dcb977
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/RootsListChangedNotificationParamsTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class RootsListChangedNotificationParamsTests
+{
+ [Fact]
+ public static void RootsListChangedNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new RootsListChangedNotificationParams
+ {
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void RootsListChangedNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new RootsListChangedNotificationParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/SamplingCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SamplingCapabilityTests.cs
new file mode 100644
index 0000000..2b8321d
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/SamplingCapabilityTests.cs
@@ -0,0 +1,37 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class SamplingCapabilityTests
+{
+ [Fact]
+ public static void SamplingCapability_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new SamplingCapability
+ {
+ Context = new SamplingContextCapability(),
+ Tools = new SamplingToolsCapability()
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Context);
+ Assert.NotNull(deserialized.Tools);
+ }
+
+ [Fact]
+ public static void SamplingCapability_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new SamplingCapability();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Context);
+ Assert.Null(deserialized.Tools);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs
new file mode 100644
index 0000000..ca698ef
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs
@@ -0,0 +1,54 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ServerCapabilitiesTests
+{
+ [Fact]
+ public static void ServerCapabilities_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ServerCapabilities
+ {
+ Logging = new LoggingCapability(),
+ Prompts = new PromptsCapability { ListChanged = true },
+ Resources = new ResourcesCapability { Subscribe = true, ListChanged = true },
+ Tools = new ToolsCapability { ListChanged = false },
+ Completions = new CompletionsCapability(),
+ Tasks = new McpTasksCapability()
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Logging);
+ Assert.NotNull(deserialized.Prompts);
+ Assert.True(deserialized.Prompts.ListChanged);
+ Assert.NotNull(deserialized.Resources);
+ Assert.True(deserialized.Resources.Subscribe);
+ Assert.True(deserialized.Resources.ListChanged);
+ Assert.NotNull(deserialized.Tools);
+ Assert.False(deserialized.Tools.ListChanged);
+ Assert.NotNull(deserialized.Completions);
+ Assert.NotNull(deserialized.Tasks);
+ }
+
+ [Fact]
+ public static void ServerCapabilities_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ServerCapabilities();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Experimental);
+ Assert.Null(deserialized.Logging);
+ Assert.Null(deserialized.Prompts);
+ Assert.Null(deserialized.Resources);
+ Assert.Null(deserialized.Tools);
+ Assert.Null(deserialized.Completions);
+ Assert.Null(deserialized.Tasks);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/SetLevelRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SetLevelRequestParamsTests.cs
new file mode 100644
index 0000000..7fc2e81
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/SetLevelRequestParamsTests.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class SetLevelRequestParamsTests
+{
+ [Fact]
+ public static void SetLevelRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new SetLevelRequestParams
+ {
+ Level = LoggingLevel.Debug,
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(LoggingLevel.Debug, deserialized.Level);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void SetLevelRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new SetLevelRequestParams
+ {
+ Level = LoggingLevel.Critical
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(LoggingLevel.Critical, deserialized.Level);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/SubscribeRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SubscribeRequestParamsTests.cs
new file mode 100644
index 0000000..7c1994c
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/SubscribeRequestParamsTests.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class SubscribeRequestParamsTests
+{
+ [Fact]
+ public static void SubscribeRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new SubscribeRequestParams
+ {
+ Uri = "file:///home/user/data.json",
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///home/user/data.json", deserialized.Uri);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void SubscribeRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new SubscribeRequestParams
+ {
+ Uri = "file:///resource"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///resource", deserialized.Uri);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolAnnotationsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolAnnotationsTests.cs
new file mode 100644
index 0000000..63d8d0e
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ToolAnnotationsTests.cs
@@ -0,0 +1,46 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ToolAnnotationsTests
+{
+ [Fact]
+ public static void ToolAnnotations_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ToolAnnotations
+ {
+ Title = "My Tool",
+ DestructiveHint = true,
+ IdempotentHint = false,
+ OpenWorldHint = true,
+ ReadOnlyHint = false
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("My Tool", deserialized.Title);
+ Assert.True(deserialized.DestructiveHint);
+ Assert.False(deserialized.IdempotentHint);
+ Assert.True(deserialized.OpenWorldHint);
+ Assert.False(deserialized.ReadOnlyHint);
+ }
+
+ [Fact]
+ public static void ToolAnnotations_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ToolAnnotations();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Title);
+ Assert.Null(deserialized.DestructiveHint);
+ Assert.Null(deserialized.IdempotentHint);
+ Assert.Null(deserialized.OpenWorldHint);
+ Assert.Null(deserialized.ReadOnlyHint);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolListChangedNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolListChangedNotificationParamsTests.cs
new file mode 100644
index 0000000..03a33b0
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ToolListChangedNotificationParamsTests.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ToolListChangedNotificationParamsTests
+{
+ [Fact]
+ public static void ToolListChangedNotificationParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ToolListChangedNotificationParams
+ {
+ Meta = new JsonObject { ["key"] = "value" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("value", (string)deserialized.Meta["key"]!);
+ }
+
+ [Fact]
+ public static void ToolListChangedNotificationParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ToolListChangedNotificationParams();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs
index 75e837c..bfe9840 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs
@@ -91,4 +91,51 @@ public static class ToolTests
Assert.Contains("\"annotations\":", json);
Assert.Contains("\"inputSchema\":", json);
}
+
+ [Fact]
+ public static void ToolInputSchema_HasValidDefaultSchema()
+ {
+ var tool = new Tool();
+ JsonElement jsonElement = tool.InputSchema;
+
+ Assert.Equal(JsonValueKind.Object, jsonElement.ValueKind);
+ Assert.Single(jsonElement.EnumerateObject());
+ Assert.True(jsonElement.TryGetProperty("type", out JsonElement typeElement));
+ Assert.Equal(JsonValueKind.String, typeElement.ValueKind);
+ Assert.Equal("object", typeElement.GetString());
+ }
+
+ [Theory]
+ [InlineData("null")]
+ [InlineData("false")]
+ [InlineData("true")]
+ [InlineData("3.5e3")]
+ [InlineData("[]")]
+ [InlineData("{}")]
+ [InlineData("""{"properties":{}}""")]
+ [InlineData("""{"type":"number"}""")]
+ [InlineData("""{"type":"array"}""")]
+ [InlineData("""{"type":["object"]}""")]
+ public static void ToolInputSchema_RejectsInvalidSchemaDocuments(string invalidSchema)
+ {
+ using var document = JsonDocument.Parse(invalidSchema);
+ var tool = new Tool();
+
+ Assert.Throws(() => tool.InputSchema = document.RootElement);
+ }
+
+ [Theory]
+ [InlineData("""{"type":"object"}""")]
+ [InlineData("""{"type":"object", "properties": {}, "required" : [] }""")]
+ [InlineData("""{"type":"object", "title": "MyAwesomeTool", "description": "It's awesome!", "properties": {}, "required" : ["NotAParam"] }""")]
+ public static void ToolInputSchema_AcceptsValidSchemaDocuments(string validSchema)
+ {
+ using var document = JsonDocument.Parse(validSchema);
+ Tool tool = new()
+ {
+ InputSchema = document.RootElement
+ };
+
+ Assert.True(JsonElement.DeepEquals(document.RootElement, tool.InputSchema));
+ }
}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolsCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolsCapabilityTests.cs
new file mode 100644
index 0000000..6d3a242
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ToolsCapabilityTests.cs
@@ -0,0 +1,34 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class ToolsCapabilityTests
+{
+ [Fact]
+ public static void ToolsCapability_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new ToolsCapability
+ {
+ ListChanged = true
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.True(deserialized.ListChanged);
+ }
+
+ [Fact]
+ public static void ToolsCapability_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new ToolsCapability();
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.ListChanged);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/UnsubscribeRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UnsubscribeRequestParamsTests.cs
new file mode 100644
index 0000000..3f47163
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/UnsubscribeRequestParamsTests.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class UnsubscribeRequestParamsTests
+{
+ [Fact]
+ public static void UnsubscribeRequestParams_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new UnsubscribeRequestParams
+ {
+ Uri = "file:///home/user/data.json",
+ Meta = new JsonObject { ["progressToken"] = "tok-1" }
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///home/user/data.json", deserialized.Uri);
+ Assert.NotNull(deserialized.Meta);
+ Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!);
+ }
+
+ [Fact]
+ public static void UnsubscribeRequestParams_SerializationRoundTrip_WithMinimalProperties()
+ {
+ var original = new UnsubscribeRequestParams
+ {
+ Uri = "file:///resource"
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("file:///resource", deserialized.Uri);
+ Assert.Null(deserialized.Meta);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationRequiredErrorDataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationRequiredErrorDataTests.cs
new file mode 100644
index 0000000..a3a47a1
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationRequiredErrorDataTests.cs
@@ -0,0 +1,43 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+public static class UrlElicitationRequiredErrorDataTests
+{
+ [Fact]
+ public static void UrlElicitationRequiredErrorData_SerializationRoundTrip_PreservesAllProperties()
+ {
+ var original = new UrlElicitationRequiredErrorData
+ {
+ Elicitations =
+ [
+ new ElicitRequestParams
+ {
+ Mode = "url",
+ ElicitationId = "elicit-1",
+ Url = "https://example.com/auth",
+ Message = "Please authenticate"
+ },
+ new ElicitRequestParams
+ {
+ Mode = "url",
+ ElicitationId = "elicit-2",
+ Url = "https://example.com/consent",
+ Message = "Please consent"
+ }
+ ]
+ };
+
+ string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(2, deserialized.Elicitations.Count);
+ Assert.Equal("url", deserialized.Elicitations[0].Mode);
+ Assert.Equal("elicit-1", deserialized.Elicitations[0].ElicitationId);
+ Assert.Equal("https://example.com/auth", deserialized.Elicitations[0].Url);
+ Assert.Equal("Please authenticate", deserialized.Elicitations[0].Message);
+ Assert.Equal("elicit-2", deserialized.Elicitations[1].ElicitationId);
+ }
+}
From 49fbc513f0ca53ce3822683bc95126178536cdd8 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:39:50 -0500
Subject: [PATCH 048/320] Align `InitializeResult.instructions` XML docs with
latest MCP spec guidance (#1290)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub
Co-authored-by: Stephen Halter
---
src/ModelContextProtocol.Core/Client/McpClient.cs | 6 +++---
src/ModelContextProtocol.Core/Protocol/InitializeResult.cs | 7 ++++---
src/ModelContextProtocol.Core/Server/McpServerOptions.cs | 5 +++--
3 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs
index abd556f..28a37f2 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.cs
@@ -35,11 +35,11 @@ public abstract partial class McpClient : McpSession
///
///
/// This property contains instructions provided by the server during initialization that explain
- /// how to effectively use its capabilities. These instructions can include details about available
- /// tools, expected input formats, limitations, or any other helpful information.
+ /// how to effectively use its capabilities. They should focus on guidance that helps a model
+ /// use the server effectively and should avoid duplicating tool, prompt, or resource descriptions.
///
///
- /// This can be used by clients to improve an LLM's understanding of available tools, prompts, and resources.
+ /// This can be used by clients to improve an LLM's understanding of how to use the server.
/// It can be thought of like a "hint" to the model and can be added to a system prompt.
///
///
diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs
index 095b5ff..e791136 100644
--- a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs
@@ -65,9 +65,10 @@ public sealed class InitializeResult : Result
///
///
///
- /// These instructions provide guidance to clients on how to effectively use the server's capabilities.
- /// They can include details about available tools, expected input formats, limitations,
- /// or any other information that helps clients interact with the server properly.
+ /// These instructions should focus on guidance that helps a model use the server effectively,
+ /// such as workflow tips, capability relationships, and server-specific conventions.
+ /// They should avoid repeating tool descriptions, prompt descriptions, or resource descriptions
+ /// that are already available through other protocol responses.
///
///
/// Client applications often use these instructions as system messages for LLM interactions
diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
index e13b4d3..9d30c72 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
@@ -54,8 +54,9 @@ public sealed class McpServerOptions
///
///
/// These instructions are sent to clients during the initialization handshake and provide
- /// guidance on how to effectively use the server's capabilities. They can include details
- /// about available tools, expected input formats, limitations, or other helpful information.
+ /// guidance on how to effectively use the server's capabilities. They should focus on
+ /// information that helps models use the server effectively and should not duplicate
+ /// tool, prompt, or resource descriptions already exposed elsewhere.
/// Client applications typically use these instructions as system messages for LLM interactions
/// to provide context about available functionality.
///
From 43a302ffa9e17064eae9b076462fde0fcf0cbc52 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 16:02:14 -0500
Subject: [PATCH 049/320] Align sampling specification XML docs with spec
revision d165cd6 (#1293)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Protocol/CreateMessageRequestParams.cs | 25 +++++++++++++++++++
.../Protocol/CreateMessageResult.cs | 2 +-
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
index 59be7fa..07f52e6 100644
--- a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
@@ -33,8 +33,13 @@ public sealed class CreateMessageRequestParams : RequestParams
/// Gets or sets the maximum number of tokens to generate in the LLM response, as requested by the server.
///
///
+ ///
/// A token is generally a word or part of a word in the text. Setting this value helps control
/// response length and computation time. The client can choose to sample fewer tokens than requested.
+ ///
+ ///
+ /// The client must respect the parameter.
+ ///
///
[JsonPropertyName("maxTokens")]
public required int MaxTokens { get; set; }
@@ -42,6 +47,9 @@ public sealed class CreateMessageRequestParams : RequestParams
///
/// Gets or sets the messages requested by the server to be included in the prompt.
///
+ ///
+ /// The list of messages in a sampling request should not be retained between separate requests.
+ ///
[JsonPropertyName("messages")]
public IList Messages { get; set; } = [];
@@ -49,9 +57,14 @@ public sealed class CreateMessageRequestParams : RequestParams
/// Gets or sets optional metadata to pass through to the LLM provider.
///
///
+ ///
/// The format of this metadata is provider-specific and can include model-specific settings or
/// configuration that isn't covered by standard parameters. This allows for passing custom parameters
/// that are specific to certain AI models or providers.
+ ///
+ ///
+ /// The client may modify or ignore metadata.
+ ///
///
[JsonPropertyName("metadata")]
public JsonElement? Metadata { get; set; }
@@ -90,6 +103,9 @@ public sealed class CreateMessageRequestParams : RequestParams
/// sequence exactly matches one of the provided sequences. Common uses include ending markers like "END", punctuation
/// like ".", or special delimiter sequences like "###".
///
+ ///
+ /// The client may modify or ignore stop sequences.
+ ///
///
[JsonPropertyName("stopSequences")]
public IList? StopSequences { get; set; }
@@ -106,6 +122,15 @@ public sealed class CreateMessageRequestParams : RequestParams
///
/// Gets or sets the temperature to use for sampling, as requested by the server.
///
+ ///
+ ///
+ /// Temperature controls randomness in model responses. Higher values produce higher randomness,
+ /// and lower values produce more stable output. The valid range depends on the model provider.
+ ///
+ ///
+ /// The client may modify or ignore this value.
+ ///
+ ///
[JsonPropertyName("temperature")]
public float? Temperature { get; set; }
diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs
index 2824ed8..9447242 100644
--- a/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs
@@ -41,7 +41,7 @@ public sealed class CreateMessageResult : Result
///
/// Standard values include:
///
- /// - endTurnThe model naturally completed its response.
+ /// - endTurnThe participant is yielding the conversation to the other party.
/// - maxTokensThe response was truncated due to reaching token limits.
/// - stopSequenceA specific stop sequence was encountered during generation.
/// - toolUseThe model wants to use one or more tools.
From 2e17550303c095e5f4230702636aff5e4830f7a5 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 16:03:10 -0500
Subject: [PATCH 050/320] Add X-Accel-Buffering header to SSE responses (#1294)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../StreamableHttpHandler.cs | 1 +
.../SseServerIntegrationTests.cs | 1 +
.../StreamableHttpServerConformanceTests.cs | 11 +++++++++++
3 files changed, 13 insertions(+)
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index 22c8613..665b754 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -339,6 +339,7 @@ internal sealed class StreamableHttpHandler(
// Make sure we disable all response buffering for SSE.
context.Response.Headers.ContentEncoding = "identity";
+ context.Response.Headers["X-Accel-Buffering"] = "no";
context.Features.GetRequiredFeature().DisableBuffering();
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTests.cs
index eb7db01..0bf4aff 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTests.cs
@@ -26,6 +26,7 @@ public class SseServerIntegrationTests(SseServerIntegrationTestFixture fixture,
Assert.NotNull(sseResponse.Headers.CacheControl);
Assert.True(sseResponse.Headers.CacheControl.NoStore);
Assert.True(sseResponse.Headers.CacheControl.NoCache);
+ Assert.Equal("no", Assert.Single(sseResponse.Headers.GetValues("X-Accel-Buffering")));
}
[Fact]
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
index a96c121..ca1c46b 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
@@ -92,6 +92,17 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
Assert.Equal("text/event-stream", Assert.Single(response.Content.Headers.GetValues("content-type")));
}
+ [Fact]
+ public async Task SseResponse_Includes_XAccelBufferingHeader()
+ {
+ await StartAsync();
+
+ using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
+ Assert.Equal("no", Assert.Single(response.Headers.GetValues("X-Accel-Buffering")));
+ }
+
[Fact]
public async Task PostRequest_IsUnsupportedMediaType_WithoutJsonContentType()
{
From 6a38242fc2b66e46414f6953e4eeaee5dbc4da05 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 16:05:01 -0500
Subject: [PATCH 051/320] Clarify request-scoped tool semantics in
CreateMessageRequestParams (#1295)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Protocol/CreateMessageRequestParams.cs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
index 07f52e6..2c029ba 100644
--- a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
@@ -137,12 +137,19 @@ public sealed class CreateMessageRequestParams : RequestParams
///
/// Gets or sets tools that the model can use during generation.
///
+ ///
+ /// The tool definitions in this array are scoped to this sampling request.
+ /// They do not need to correspond to tools registered on the server via .
+ ///
[JsonPropertyName("tools")]
public IList? Tools { get; set; }
///
/// Gets or sets controls for how the model uses tools.
///
+ ///
+ /// This controls whether and how the model uses the request-scoped during sampling.
+ ///
[JsonPropertyName("toolChoice")]
public ToolChoice? ToolChoice { get; set; }
From 6fa8b50affa5b0c6e71d6856f2db8fe1cba1a0e3 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:05:31 -0800
Subject: [PATCH 052/320] Fix flaky sse-retry conformance test caused by CI
timing sensitivity (#1279)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub
---
.../ClientConformanceTests.cs | 29 +++++++++++++++++--
1 file changed, 26 insertions(+), 3 deletions(-)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
index 295fc9b..f9a5c1a 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Text;
+using System.Text.RegularExpressions;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
@@ -118,10 +119,32 @@ public class ClientConformanceTests
);
}
+ var output = outputBuilder.ToString();
+ var error = errorBuilder.ToString();
+ var success = process.ExitCode == 0 || HasOnlyWarnings(output, error);
+
return (
- Success: process.ExitCode == 0,
- Output: outputBuilder.ToString(),
- Error: errorBuilder.ToString()
+ Success: success,
+ Output: output,
+ Error: error
);
}
+
+ ///
+ /// Checks if the conformance test output indicates that all checks passed with only
+ /// warnings (no actual failures). The conformance runner exits with code 1 for warnings,
+ /// but warnings represent acceptable behavior (e.g., timing tolerances in CI environments).
+ ///
+ private static bool HasOnlyWarnings(string output, string error)
+ {
+ // The conformance runner outputs a summary line like:
+ // "Passed: 2/2, 0 failed, 1 warnings"
+ // If there are 0 failures but warnings > 0, the test behavior is acceptable.
+ var combined = output + error;
+ var match = Regex.Match(combined, @"(?\d+) failed, (?\d+) warnings");
+ return match.Success
+ && match.Groups["failed"].Value == "0"
+ && int.TryParse(match.Groups["warnings"].Value, out var warnings)
+ && warnings > 0;
+ }
}
From 5651e9ebfe1b092f69c2ed7ffeadc2150ebd73af Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:11:10 -0800
Subject: [PATCH 053/320] Bump the serilog-testing group with 1 update (#1282)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3f5540f..658f571 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -87,7 +87,7 @@
-
+
From 186cb93630d4bf4a5c6c8880fc07084610ef06b1 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:12:29 -0800
Subject: [PATCH 054/320] Update McpErrorCode XML docs and fix error code usage
to align with MCP spec (#1291)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
docs/concepts/tasks/tasks.md | 4 +--
src/ModelContextProtocol.Core/McpErrorCode.cs | 27 ++++++++++++++-----
.../Server/McpServerImpl.cs | 4 +--
.../McpServerTaskAugmentedValidationTests.cs | 4 +--
.../TaskCancellationIntegrationTests.cs | 4 +--
.../Server/ToolTaskSupportTests.cs | 4 +--
6 files changed, 30 insertions(+), 17 deletions(-)
diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md
index ee7ee83..9b67e11 100644
--- a/docs/concepts/tasks/tasks.md
+++ b/docs/concepts/tasks/tasks.md
@@ -424,8 +424,8 @@ Task operations may throw with these er
| Error Code | Scenario |
|------------|----------|
-| `InvalidParams` | Invalid or nonexistent task ID |
-| `InvalidRequest` | Tool with `taskSupport: forbidden` called with task metadata, or tool with `taskSupport: required` called without task metadata |
+| `InvalidParams` | Invalid or nonexistent task ID or invalid cursor |
+| `InvalidParams` | Tool with `taskSupport: forbidden` called with task metadata, or tool with `taskSupport: required` called without task metadata |
| `InternalError` | Task execution failure or result unavailable |
Example error handling:
diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs
index c6610d6..33cd74a 100644
--- a/src/ModelContextProtocol.Core/McpErrorCode.cs
+++ b/src/ModelContextProtocol.Core/McpErrorCode.cs
@@ -39,10 +39,19 @@ public enum McpErrorCode
InvalidRequest = -32600,
///
- /// Indicates that the requested method does not exist or is not available on the server.
+ /// Indicates that the requested method does not exist or is not available.
///
///
- /// This error is returned when the method name specified in the request cannot be found.
+ ///
+ /// In MCP, this error is returned when a request is made for a method that requires a capability
+ /// that has not been declared. This can occur in either direction:
+ ///
+ ///
+ /// - A server returning this error when the client requests a capability it doesn't support
+ /// (for example, requesting completions when the completions capability was not advertised).
+ /// - A client returning this error when the server requests a capability it doesn't support
+ /// (for example, requesting roots when the client did not declare the roots capability).
+ ///
///
MethodNotFound = -32601,
@@ -51,15 +60,19 @@ public enum McpErrorCode
///
///
///
- /// This error is returned for protocol-level parameter issues, such as:
+ /// In MCP, this error is returned for protocol-level parameter validation failures in various contexts:
///
///
- /// - Malformed requests that fail to satisfy the request schema (for example, CallToolRequest)
- /// - Unknown or unrecognized primitive names (for example, tool, prompt, or resource names)
- /// - Missing required protocol-level parameters
+ /// - Tools: Unknown tool name or invalid protocol-level tool arguments.
+ /// - Prompts: Unknown prompt name or missing required protocol-level arguments.
+ /// - Pagination: Invalid or expired cursor values.
+ /// - Logging: Invalid log level.
+ /// - Tasks: Invalid or nonexistent task ID or invalid cursor.
+ /// - Elicitation: Server requests an elicitation mode not declared in client capabilities.
+ /// - Sampling: Missing tool result or tool results mixed with other content.
///
///
- /// Note: Input validation errors within tool/prompt/resource arguments should be reported as execution errors
+ /// Note: Application-layer validation errors within tool/prompt/resource arguments should be reported as execution errors
/// (for example, via ) rather than as protocol errors, allowing language
/// models to receive error feedback and self-correct.
///
diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index 04f3294..7dfc0f0 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -591,7 +591,7 @@ internal sealed partial class McpServerImpl : McpServer
{
throw new McpProtocolException(
$"Tool '{tool.ProtocolTool.Name}' does not support task-augmented execution.",
- McpErrorCode.MethodNotFound);
+ McpErrorCode.InvalidParams);
}
// Task augmentation requested - return CreateTaskResult
@@ -604,7 +604,7 @@ internal sealed partial class McpServerImpl : McpServer
throw new McpProtocolException(
$"Tool '{tool.ProtocolTool.Name}' requires task-augmented execution. " +
"Include a 'task' parameter with the request.",
- McpErrorCode.MethodNotFound);
+ McpErrorCode.InvalidParams);
}
// Normal synchronous execution
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs
index 33655f9..b7734d7 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs
@@ -100,7 +100,7 @@ public class McpServerTaskAugmentedValidationTests : LoggedTest
TestContext.Current.CancellationToken));
Assert.Contains("does not support task-augmented execution", exception.Message, StringComparison.OrdinalIgnoreCase);
- Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
+ Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
}
[Fact]
@@ -227,7 +227,7 @@ public class McpServerTaskAugmentedValidationTests : LoggedTest
TestContext.Current.CancellationToken));
Assert.Contains("requires task-augmented execution", exception.Message, StringComparison.OrdinalIgnoreCase);
- Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
+ Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
}
[Fact]
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
index 7367b53..cc075a6 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
@@ -460,7 +460,7 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase
Assert.Equal(McpTaskStatus.Completed, taskStatus.Status);
- // Act - Try to cancel a completed task
+ // Act - Try to cancel a completed task (should be idempotent)
var cancelResult = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
// Assert - Status should still be completed (not cancelled)
@@ -500,7 +500,7 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase
Assert.Equal(McpTaskStatus.Failed, taskStatus.Status);
- // Act - Try to cancel a failed task
+ // Act - Try to cancel a failed task (should be idempotent)
var cancelResult = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
// Assert - Status should still be failed
diff --git a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs b/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
index 99c6035..25db2b3 100644
--- a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
@@ -526,8 +526,8 @@ public class ToolTaskSupportTests : LoggedTest
arguments: new Dictionary { ["input"] = "test" },
cancellationToken: TestContext.Current.CancellationToken).AsTask());
- // The server returns MethodNotFound because direct invocation is not allowed for required-task tools
- Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
+ // The server returns InvalidParams because direct invocation is not allowed for required-task tools
+ Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
Assert.Contains("task", exception.Message, StringComparison.OrdinalIgnoreCase);
}
From 841e81016783e389b190a0bef03a3a6a53be8b75 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 16:00:12 -0800
Subject: [PATCH 055/320] Fix StdioServerTransport.DisposeAsync() hang:
CancellableStdinStream missing Dispose override (#1276)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Server/StdioServerTransport.cs | 10 ++++++++++
.../Transport/StdioServerTransportTests.cs | 2 +-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.Core/Server/StdioServerTransport.cs b/src/ModelContextProtocol.Core/Server/StdioServerTransport.cs
index 307c180..f434263 100644
--- a/src/ModelContextProtocol.Core/Server/StdioServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StdioServerTransport.cs
@@ -59,6 +59,16 @@ public sealed class StdioServerTransport : StreamServerTransport
}
#endif
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ stdinStream.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
// The McpServer shouldn't call flush on the stdin Stream, but it doesn't need to throw just in case.
public override void Flush() { }
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
index 54a1a96..a993b41 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
@@ -24,7 +24,7 @@ public class StdioServerTransportTests : LoggedTest
};
}
- [Fact(Skip="https://github.com/modelcontextprotocol/csharp-sdk/issues/143")]
+ [Fact]
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
{
// Act
From ec4c7326324bcd92452be56834db324dabb81d17 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 19:45:48 -0500
Subject: [PATCH 056/320] Add validation and error signaling guidance to MCP
tool XML docs (#1275)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
src/ModelContextProtocol.Core/McpException.cs | 5 +++++
.../Protocol/CallToolResult.cs | 7 +++++++
.../Protocol/Tool.cs | 10 +++++++++-
.../Server/McpServerTool.cs | 20 +++++++++++++++++++
.../Server/McpServerToolAttribute.cs | 20 +++++++++++++++++++
5 files changed, 61 insertions(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.Core/McpException.cs b/src/ModelContextProtocol.Core/McpException.cs
index 5abdb04..101f858 100644
--- a/src/ModelContextProtocol.Core/McpException.cs
+++ b/src/ModelContextProtocol.Core/McpException.cs
@@ -12,6 +12,11 @@ namespace ModelContextProtocol;
///
/// This exception type can be thrown by MCP tools or tool call filters to propagate detailed error messages
/// from when a tool execution fails via a .
+/// This includes input validation errors, business logic errors, or any other failure that the model should
+/// be informed about. For example, if a required field is missing or a value is out of range, throwing an
+/// with a descriptive message allows the model to understand the issue and
+/// potentially self-correct in a subsequent request.
+///
/// For non-tool calls, this exception controls the message propagated via a .
///
/// is a derived type that can be used to also specify the
diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
index 5094360..e760d2f 100644
--- a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
@@ -14,6 +14,13 @@ namespace ModelContextProtocol.Protocol;
/// and potentially self-correct in subsequent requests.
///
///
+/// To return a validation or business-logic error from a tool method, either throw an
+/// (whose will be included in the error result), or declare the tool's return type
+/// as so it can be returned directly with set to
+/// and details in . Using as the return type gives the tool full control
+/// over both success and error responses.
+///
+///
/// Protocol-level errors (such as unknown tool names, malformed requests that fail schema validation,
/// or server errors) should be reported as MCP protocol error responses using .
///
diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs
index 8e06d71..4c14756 100644
--- a/src/ModelContextProtocol.Core/Protocol/Tool.cs
+++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs
@@ -31,7 +31,15 @@ public sealed class Tool : IBaseMetadata
///
///
/// The description is typically presented to AI models to help them determine when
- /// and how to use the tool based on user requests.
+ /// and how to use the tool based on user requests. A well-written description significantly
+ /// reduces incorrect tool invocations. Include information about what the tool does, any
+ /// constraints or prerequisites, and what it returns.
+ ///
+ ///
+ /// Similarly, individual parameter descriptions (provided via
+ /// on tool method parameters) are important for guiding the model to supply correct argument values.
+ /// Descriptions should document expected formats, valid value ranges, and any other constraints
+ /// the model should be aware of.
///
///
[JsonPropertyName("description")]
diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs
index f2e7538..9e8b84f 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs
@@ -90,6 +90,26 @@ namespace ModelContextProtocol.Server;
/// to provide data to the method.
///
///
+/// The tool method is responsible for validating its own input arguments (e.g., checking required fields, value ranges, string lengths, or
+/// any other business rules). Data annotations such as RequiredAttribute and
+/// MaxLengthAttribute on parameter types influence the generated JSON schema exposed
+/// to clients, but they are not enforced at runtime by the SDK. Validation should be performed explicitly within the tool method.
+///
+///
+/// To signal an error (including validation failures) back to the client, either throw an
+/// or return a with set to .
+/// When a tool throws an , its is included in the error result
+/// sent to the client. Throwing any other exception type also results in an error , but with
+/// a generic error message (to avoid leaking sensitive information). Alternatively, a tool can declare a return type of
+/// to have full control over both success and error responses.
+///
+///
+/// It is important to provide clear values on tool methods and their parameters.
+/// These descriptions are surfaced to AI models and help them determine when and how to use the tool, what values to pass for each parameter,
+/// and what constraints the parameters have. Well-written descriptions reduce incorrect tool invocations and improve the quality of
+/// model interactions.
+///
+///
/// Return values from a method are used to create the that is sent back to the client:
///
///
diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
index bde0971..9653a32 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
@@ -82,6 +82,26 @@ namespace ModelContextProtocol.Server;
/// to provide data to the method.
///
///
+/// The tool method is responsible for validating its own input arguments (e.g., checking required fields, value ranges, string lengths, or
+/// any other business rules). Data annotations such as RequiredAttribute and
+/// MaxLengthAttribute on parameter types influence the generated JSON schema exposed
+/// to clients, but they are not enforced at runtime by the SDK. Validation should be performed explicitly within the tool method.
+///
+///
+/// To signal an error (including validation failures) back to the client, either throw an
+/// or return a with set to .
+/// When a tool throws an , its is included in the error result
+/// sent to the client. Throwing any other exception type also results in an error , but with
+/// a generic error message (to avoid leaking sensitive information). Alternatively, a tool can declare a return type of
+/// to have full control over both success and error responses.
+///
+///
+/// It is important to provide clear values on tool methods and their parameters.
+/// These descriptions are surfaced to AI models and help them determine when and how to use the tool, what values to pass for each parameter,
+/// and what constraints the parameters have. Well-written descriptions reduce incorrect tool invocations and improve the quality of
+/// model interactions.
+///
+///
/// Return values from a method are used to create the that is sent back to the client:
///
///
From a502efb70369e7109dd5d78760aa595e29f350b8 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 16:49:12 -0800
Subject: [PATCH 057/320] Update tests for
@modelcontextprotocol/server-everything 2026.1.26 (#1273)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
package-lock.json | 15 +--
package.json | 2 +-
.../Program.cs | 71 +++++++++------
.../ClientIntegrationTestFixture.cs | 2 +-
.../ClientIntegrationTests.cs | 91 ++++++++++---------
.../DockerEverythingServerTests.cs | 4 +-
6 files changed, 107 insertions(+), 78 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index aca8545..36b04d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "mcp",
+ "name": "csharp-sdk",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@modelcontextprotocol/conformance": "0.1.13",
- "@modelcontextprotocol/server-everything": "2025.12.18",
+ "@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
},
@@ -82,12 +82,12 @@
}
},
"node_modules/@modelcontextprotocol/server-everything": {
- "version": "2025.12.18",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-everything/-/server-everything-2025.12.18.tgz",
- "integrity": "sha512-S+MYITzTp02eLSxT+abAMDXf2vjubVKUbdjVj5HW9aEepa/lkcdcSZHeI8PpxpnZOQOW28FKGyFEG/QdawyAeA==",
+ "version": "2026.1.26",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-everything/-/server-everything-2026.1.26.tgz",
+ "integrity": "sha512-RIEXAQuKZeXZqFzJMqLDlzmMmG5sjq2uhS/VqzF4HIZ3w1V73YcWf+UXkxJueJuHMBO3Z8ceBrQ5wI6o/plZ+Q==",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/sdk": "^1.25.2",
"cors": "^2.8.5",
"express": "^5.2.1",
"jszip": "^3.10.1",
@@ -430,6 +430,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -634,6 +635,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1294,6 +1296,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 4d007f0..b05f143 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
"dependencies": {
"@modelcontextprotocol/conformance": "0.1.13",
- "@modelcontextprotocol/server-everything": "2025.12.18",
+ "@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
}
diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs
index fc70bdf..7ac6513 100644
--- a/tests/ModelContextProtocol.TestServer/Program.cs
+++ b/tests/ModelContextProtocol.TestServer/Program.cs
@@ -142,8 +142,8 @@ internal static class Program
},
new Tool
{
- Name = "sampleLLM",
- Description = "Samples from an LLM using MCP's sampling feature.",
+ Name = "trigger-sampling-request",
+ Description = "Trigger a Request from the Server for LLM Sampling",
InputSchema = JsonElement.Parse("""
{
"type": "object",
@@ -205,7 +205,7 @@ internal static class Program
Content = [new TextContentBlock { Text = request.Server.SessionId ?? string.Empty }]
};
}
- else if (request.Params?.Name == "sampleLLM")
+ else if (request.Params?.Name == "trigger-sampling-request")
{
if (request.Params?.Arguments is null ||
!request.Params.Arguments.TryGetValue("prompt", out var prompt) ||
@@ -213,7 +213,7 @@ internal static class Program
{
throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams);
}
- var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "sampleLLM", Convert.ToInt32(maxTokens.GetRawText())),
+ var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "trigger-sampling-request", Convert.ToInt32(maxTokens.GetRawText())),
cancellationToken: cancellationToken);
return new CallToolResult
@@ -257,28 +257,48 @@ internal static class Program
Prompts = [
new Prompt
{
- Name = "simple_prompt",
+ Name = "simple-prompt",
Description = "A prompt without arguments"
},
new Prompt
{
- Name = "complex_prompt",
+ Name = "args-prompt",
Description = "A prompt with arguments",
Arguments =
[
new PromptArgument
{
- Name = "temperature",
- Description = "Temperature setting",
+ Name = "city",
+ Description = "Name of the city",
Required = true
},
new PromptArgument
{
- Name = "style",
- Description = "Output style",
+ Name = "state",
+ Description = "Name of the state",
Required = false
}
]
+ },
+ new Prompt
+ {
+ Name = "completable-prompt",
+ Description = "A prompt with completable arguments",
+ Arguments =
+ [
+ new PromptArgument
+ {
+ Name = "department",
+ Description = "Choose the department",
+ Required = true
+ },
+ new PromptArgument
+ {
+ Name = "name",
+ Description = "Choose a team member",
+ Required = true
+ }
+ ]
}
]
};
@@ -287,7 +307,7 @@ internal static class Program
options.Handlers.GetPromptHandler = async (request, cancellationToken) =>
{
List messages = [];
- if (request.Params?.Name == "simple_prompt")
+ if (request.Params?.Name == "simple-prompt")
{
messages.Add(new PromptMessage
{
@@ -295,28 +315,25 @@ internal static class Program
Content = new TextContentBlock { Text = "This is a simple prompt without arguments." },
});
}
- else if (request.Params?.Name == "complex_prompt")
+ else if (request.Params?.Name == "args-prompt")
{
- string temperature = request.Params.Arguments?["temperature"].ToString() ?? "unknown";
- string style = request.Params.Arguments?["style"].ToString() ?? "unknown";
+ string city = request.Params.Arguments?["city"].ToString() ?? "unknown";
+ string state = request.Params.Arguments?["state"].ToString() ?? "";
+ string location = !string.IsNullOrEmpty(state) ? $"{city}, {state}" : city;
messages.Add(new PromptMessage
{
Role = Role.User,
- Content = new TextContentBlock { Text = $"This is a complex prompt with arguments: temperature={temperature}, style={style}" },
- });
- messages.Add(new PromptMessage
- {
- Role = Role.Assistant,
- Content = new TextContentBlock { Text = "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?" },
+ Content = new TextContentBlock { Text = $"What's weather in {location}?" },
});
+ }
+ else if (request.Params?.Name == "completable-prompt")
+ {
+ string department = request.Params.Arguments?["department"].ToString() ?? "unknown";
+ string name = request.Params.Arguments?["name"].ToString() ?? "unknown";
messages.Add(new PromptMessage
{
Role = Role.User,
- Content = new ImageContentBlock
- {
- Data = MCP_TINY_IMAGE,
- MimeType = "image/png"
- }
+ Content = new TextContentBlock { Text = $"Please promote {name} to the head of the {department} team." },
});
}
else
@@ -516,8 +533,8 @@ internal static class Program
List sampleResourceIds = ["1", "2", "3", "4", "5"];
Dictionary> exampleCompletions = new()
{
- {"style", ["casual", "formal", "technical", "friendly"]},
- {"temperature", ["0", "0.5", "0.7", "1.0"]},
+ {"department", ["Engineering", "Sales", "Marketing", "Support"]},
+ {"name", ["Alice", "Bob", "Charlie"]},
};
options.Handlers.CompleteHandler = async (request, cancellationToken) =>
diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs
index 049d72d..6f0e8e4 100644
--- a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs
+++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs
@@ -15,7 +15,7 @@ public class ClientIntegrationTestFixture
public ClientIntegrationTestFixture()
{
- const string ServerEverythingVersion = "2025.12.18";
+ const string ServerEverythingVersion = "2026.1.26";
EverythingServerTransportOptions = new()
{
diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs
index 018e12d..3eba027 100644
--- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs
+++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs
@@ -53,11 +53,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture p.Name == "simple_prompt");
- Assert.Contains(prompts, p => p.Name == "complex_prompt");
+ Assert.Contains(prompts, p => p.Name == "simple-prompt");
+ Assert.Contains(prompts, p => p.Name == "args-prompt");
}
[Theory]
@@ -158,7 +154,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture
{
- { "temperature", "0.7" },
- { "style", "formal" }
+ { "city", "Seattle" },
+ { "state", "WA" }
};
- var result = await client.GetPromptAsync("complex_prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
+ var result = await client.GetPromptAsync("args-prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
@@ -208,8 +204,8 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture allResourceTemplates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
- // The server provides a single test resource template
- Assert.Single(allResourceTemplates);
+ // The server provides test resource templates
+ Assert.NotEmpty(allResourceTemplates);
}
[Theory]
@@ -223,8 +219,8 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture allResources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- // The server provides 100 test resources
- Assert.Equal(100, allResources.Count);
+ // The server provides test resources
+ Assert.NotEmpty(allResources);
}
[Theory]
@@ -235,27 +231,29 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture r.MimeType?.StartsWith("text/", StringComparison.Ordinal) is true);
+ var result = await client.ReadResourceAsync(textResource.Uri, null, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
- TextResourceContents textResource = Assert.IsType(result.Contents[0]);
- Assert.NotNull(textResource.Text);
+ TextResourceContents textContent = Assert.IsType(result.Contents[0]);
+ Assert.NotNull(textContent.Text);
}
- [Theory]
- [MemberData(nameof(GetClients))]
- public async Task ReadResource_Stdio_BinaryResource(string clientId)
+ // The latest "everything" server only exposes text-based file resources in its resource list;
+ // binary resources are available via resource templates but not in the listed resources.
+ [Fact]
+ public async Task ReadResource_Stdio_BinaryResource()
{
// arrange
+ var clientId = "test_server";
// act
await using var client = await _fixture.CreateClientAsync(clientId);
- // Even numbered resources are binary in the everything server (despite the docs saying otherwise)
- // 2 is index 1, which is "odd" in the 0-based index
+ // Read a binary resource from the test server
var result = await client.ReadResourceAsync("test://static/resource/2", null, TestContext.Current.CancellationToken);
Assert.NotNull(result);
@@ -336,9 +334,11 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture
{
["prompt"] = "Test prompt",
@@ -536,7 +536,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture()
+ var result = await client.CallToolAsync("trigger-sampling-request", new Dictionary()
{
["prompt"] = "In just a few words, what is the most famous tower in Paris?",
}, cancellationToken: TestContext.Current.CancellationToken);
@@ -575,6 +575,12 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture(), cancellationToken: TestContext.Current.CancellationToken);
+ }
+
// assert
await receivedNotification.Task;
}
@@ -602,7 +608,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture p.Name == "simple_prompt");
+ Assert.Contains(result.Prompts, p => p.Name == "simple-prompt");
}
[Theory]
@@ -612,7 +618,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture
+ // Call the server's trigger-sampling-request tool which should trigger our sampling handler
+ var result = await client.CallToolAsync("trigger-sampling-request", new Dictionary
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
From 92cbbcaeeb73d3950765b036d50a1963e67ea66d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 17 Feb 2026 18:12:29 -0800
Subject: [PATCH 058/320] Bump the testing-frameworks group with 1 update
(#1281)
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 658f571..cd23f00 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -63,7 +63,7 @@
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
From c3bb9c1955ad69ee5d9c5a16bcf552cc36bdd887 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 17 Feb 2026 18:16:11 -0800
Subject: [PATCH 059/320] Bump ajv from 8.17.1 to 8.18.0 in the npm_and_yarn
group across 1 directory (#1302)
---
package-lock.json | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 36b04d0..a5b8928 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -124,9 +124,9 @@
}
},
"node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -430,7 +430,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -635,7 +634,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1296,7 +1294,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
From f3da4729f5d564d4c013eba68e5a7e8714944ccc Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Feb 2026 13:01:56 -0500
Subject: [PATCH 060/320] Avoid intermediate strings in MCP transport
serialization (#1274)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
---
.../Authentication/ClientOAuthProvider.cs | 5 +-
.../Client/McpHttpClient.cs | 13 +--
.../Client/StreamClientSessionTransport.cs | 85 ++++++-------------
.../Server/StreamServerTransport.cs | 6 +-
.../Transport/StdioClientTransportTests.cs | 52 ++++++++++++
.../Transport/StdioServerTransportTests.cs | 48 +++++++++++
6 files changed, 141 insertions(+), 68 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index 5b6aa86..83f664f 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -618,8 +618,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
Scope = GetScopeParameter(protectedResourceMetadata),
};
- var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
- using var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
+ var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
+ using var requestContent = new ByteArrayContent(requestBytes);
+ requestContent.Headers.ContentType = McpHttpClient.s_applicationJsonContentType;
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.RegistrationEndpoint)
{
diff --git a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs
index 77ca78f..7caf501 100644
--- a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs
+++ b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs
@@ -1,10 +1,10 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics;
+using System.Net.Http.Headers;
#if NET
using System.Net.Http.Json;
#else
-using System.Text;
using System.Text.Json;
#endif
@@ -12,6 +12,8 @@ namespace ModelContextProtocol.Client;
internal class McpHttpClient(HttpClient httpClient)
{
+ internal static readonly MediaTypeHeaderValue s_applicationJsonContentType = new("application/json") { CharSet = "utf-8" };
+
internal virtual async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
{
Debug.Assert(request.Content is null, "The request body should only be supplied as a JsonRpcMessage");
@@ -32,11 +34,10 @@ internal class McpHttpClient(HttpClient httpClient)
#if NET
return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
#else
- return new StringContent(
- JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
- Encoding.UTF8,
- "application/json"
- );
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
+ var content = new ByteArrayContent(bytes);
+ content.Headers.ContentType = s_applicationJsonContentType;
+ return content;
#endif
}
}
diff --git a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
index d582abe..1930634 100644
--- a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
@@ -8,55 +8,16 @@ namespace ModelContextProtocol.Client;
/// Provides the client side of a stream-based session transport.
internal class StreamClientSessionTransport : TransportBase
{
+ private static readonly byte[] s_newlineBytes = "\n"u8.ToArray();
+
internal static UTF8Encoding NoBomUtf8Encoding { get; } = new(encoderShouldEmitUTF8Identifier: false);
private readonly TextReader _serverOutput;
- private readonly TextWriter _serverInput;
+ private readonly Stream _serverInputStream;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private CancellationTokenSource? _shutdownCts = new();
private Task? _readTask;
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- /// The text writer connected to the server's input stream.
- /// Messages written to this writer will be sent to the server.
- ///
- ///
- /// The text reader connected to the server's output stream.
- /// Messages read from this reader will be received from the server.
- ///
- ///
- /// A name that identifies this transport endpoint in logs.
- ///
- ///
- /// Optional factory for creating loggers. If null, a NullLogger is used.
- ///
- ///
- /// This constructor starts a background task to read messages from the server output stream.
- /// The transport will be marked as connected once initialized.
- ///
- public StreamClientSessionTransport(
- TextWriter serverInput, TextReader serverOutput, string endpointName, ILoggerFactory? loggerFactory)
- : base(endpointName, loggerFactory)
- {
- _serverOutput = serverOutput;
- _serverInput = serverInput;
-
- SetConnected();
-
- // Start reading messages in the background. We use the rarer pattern of new Task + Start
- // in order to ensure that the body of the task will always see _readTask initialized.
- // It is then able to reliably null it out on completion.
- var readTask = new Task(
- thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
- this,
- TaskCreationOptions.DenyChildAttach);
- _readTask = readTask.Unwrap();
- readTask.Start();
- }
-
///
/// Initializes a new instance of the class.
///
@@ -80,18 +41,29 @@ internal class StreamClientSessionTransport : TransportBase
/// The transport will be marked as connected once initialized.
///
public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory)
- : this(
- new StreamWriter(serverInput, encoding ?? NoBomUtf8Encoding),
-#if NET
- new StreamReader(serverOutput, encoding ?? NoBomUtf8Encoding),
-#else
- new CancellableStreamReader(serverOutput, encoding ?? NoBomUtf8Encoding),
-#endif
- endpointName,
- loggerFactory)
+ : base(endpointName, loggerFactory)
{
Throw.IfNull(serverInput);
Throw.IfNull(serverOutput);
+
+ _serverInputStream = serverInput;
+#if NET
+ _serverOutput = new StreamReader(serverOutput, encoding ?? NoBomUtf8Encoding);
+#else
+ _serverOutput = new CancellableStreamReader(serverOutput, encoding ?? NoBomUtf8Encoding);
+#endif
+
+ SetConnected();
+
+ // Start reading messages in the background. We use the rarer pattern of new Task + Start
+ // in order to ensure that the body of the task will always see _readTask initialized.
+ // It is then able to reliably null it out on completion.
+ var readTask = new Task(
+ thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
+ this,
+ TaskCreationOptions.DenyChildAttach);
+ _readTask = readTask.Unwrap();
+ readTask.Start();
}
///
@@ -103,16 +75,15 @@ internal class StreamClientSessionTransport : TransportBase
id = messageWithId.Id.ToString();
}
- var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
-
- LogTransportSendingMessageSensitive(Name, json);
+ LogTransportSendingMessageSensitive(message);
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
try
{
- // Write the message followed by a newline using our UTF-8 writer
- await _serverInput.WriteLineAsync(json).ConfigureAwait(false);
- await _serverInput.FlushAsync(cancellationToken).ConfigureAwait(false);
+ var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
+ await _serverInputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false);
+ await _serverInputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
+ await _serverInputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
diff --git a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
index 1ab106e..2202337 100644
--- a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
@@ -74,9 +74,9 @@ public class StreamServerTransport : TransportBase
try
{
- var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
- LogTransportSendingMessageSensitive(Name, json);
- await _outputStream.WriteAsync(Encoding.UTF8.GetBytes(json), cancellationToken).ConfigureAwait(false);
+ var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
+ LogTransportSendingMessageSensitive(message);
+ await _outputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false);
await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
await _outputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
index c33ed7f..16cd352 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
@@ -1,8 +1,10 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Tests.Utils;
+using System.IO.Pipelines;
using System.Runtime.InteropServices;
using System.Text;
+using System.Text.Json;
namespace ModelContextProtocol.Tests.Transport;
@@ -136,4 +138,54 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
var content = Assert.IsType(Assert.Single(result.Content));
Assert.Equal(cliArgumentValue ?? "", content.Text);
}
+
+ [Fact]
+ public async Task SendMessageAsync_Should_Use_LF_Not_CRLF()
+ {
+ using var serverInput = new MemoryStream();
+ Pipe serverOutputPipe = new();
+
+ var transport = new StreamClientTransport(serverInput, serverOutputPipe.Reader.AsStream(), LoggerFactory);
+ await using var sessionTransport = await transport.ConnectAsync(TestContext.Current.CancellationToken);
+
+ var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
+
+ await sessionTransport.SendMessageAsync(message, TestContext.Current.CancellationToken);
+
+ byte[] bytes = serverInput.ToArray();
+
+ // The output should end with exactly \n (0x0A), not \r\n (0x0D 0x0A).
+ Assert.True(bytes.Length > 1, "Output should contain message data");
+ Assert.Equal((byte)'\n', bytes[^1]);
+ Assert.NotEqual((byte)'\r', bytes[^2]);
+
+ // Also verify the JSON content is valid
+ var json = Encoding.UTF8.GetString(bytes).TrimEnd('\n');
+ var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
+ Assert.Equal(expected, json);
+ }
+
+ [Fact]
+ public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages()
+ {
+ Pipe serverInputPipe = new();
+ Pipe serverOutputPipe = new();
+
+ var transport = new StreamClientTransport(serverInputPipe.Writer.AsStream(), serverOutputPipe.Reader.AsStream(), LoggerFactory);
+ await using var sessionTransport = await transport.ConnectAsync(TestContext.Current.CancellationToken);
+
+ var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
+ var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
+
+ // Write a \r\n-delimited message to the server's output (which the client reads)
+ await serverOutputPipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\r\n"), TestContext.Current.CancellationToken);
+
+ var canRead = await sessionTransport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(canRead, "Should be able to read a \\r\\n-delimited message");
+ Assert.True(sessionTransport.MessageReader.TryPeek(out var readMessage));
+ Assert.NotNull(readMessage);
+ Assert.IsType(readMessage);
+ Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
+ }
}
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
index a993b41..22ac43d 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
@@ -227,6 +227,54 @@ public class StdioServerTransportTests : LoggedTest
Assert.Contains(traceLogMessages, x => x.Message.Contains("\"method\":\"test\"") && x.Message.Contains("\"id\":44"));
}
+ [Fact]
+ public async Task SendMessageAsync_Should_Use_LF_Not_CRLF()
+ {
+ using var output = new MemoryStream();
+
+ await using var transport = new StreamServerTransport(
+ new Pipe().Reader.AsStream(),
+ output,
+ loggerFactory: LoggerFactory);
+
+ var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
+
+ await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
+
+ byte[] bytes = output.ToArray();
+
+ // The output should end with exactly \n (0x0A), not \r\n (0x0D 0x0A).
+ Assert.True(bytes.Length > 1, "Output should contain message data");
+ Assert.Equal((byte)'\n', bytes[^1]);
+ Assert.NotEqual((byte)'\r', bytes[^2]);
+ }
+
+ [Fact]
+ public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages()
+ {
+ var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
+ var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
+
+ Pipe pipe = new();
+ using var input = pipe.Reader.AsStream();
+
+ await using var transport = new StreamServerTransport(
+ input,
+ Stream.Null,
+ loggerFactory: LoggerFactory);
+
+ // Write the message with \r\n line ending
+ await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\r\n"), TestContext.Current.CancellationToken);
+
+ var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(canRead, "Should be able to read a \\r\\n-delimited message");
+ Assert.True(transport.MessageReader.TryPeek(out var readMessage));
+ Assert.NotNull(readMessage);
+ Assert.IsType(readMessage);
+ Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
+ }
+
[Fact]
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
{
From 703d842a3878edc706f0b23c54e722cb2230d7d3 Mon Sep 17 00:00:00 2001
From: Den Delimarsky <53200638+localden@users.noreply.github.com>
Date: Wed, 18 Feb 2026 15:41:14 -0800
Subject: [PATCH 061/320] Update SECURITY.md to use GitHub Security Advisories
(#1306)
---
SECURITY.md | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/SECURITY.md b/SECURITY.md
index 8c09400..5029242 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,14 +1,21 @@
# Security Policy
-Thank you for helping us keep the SDKs and systems they interact with secure.
+
+Thank you for helping keep the Model Context Protocol and its ecosystem secure.
## Reporting Security Issues
-This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project.
+If you discover a security vulnerability in this repository, please report it through
+the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
+for this repository.
-The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities.
+Please **do not** report security vulnerabilities through public GitHub issues, discussions,
+or pull requests.
-Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability).
+## What to Include
-## Vulnerability Disclosure Program
+To help us triage and respond quickly, please include:
-Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp).
+- A description of the vulnerability
+- Steps to reproduce the issue
+- The potential impact
+- Any suggested fixes (optional)
From 5da0fe506fc05143e76723be475a11ffba9dfb73 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Feb 2026 22:18:00 -0500
Subject: [PATCH 062/320] Validate MCP-Protocol-Version header in Streamable
HTTP handler (#1277)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub
---
.../StreamableHttpHandler.cs | 49 +++++++++++++++++++
.../McpSessionHandler.cs | 5 +-
.../StreamableHttpServerConformanceTests.cs | 48 ++++++++++++++++++
3 files changed, 101 insertions(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index 665b754..c784c99 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -23,8 +23,21 @@ internal sealed class StreamableHttpHandler(
ILoggerFactory loggerFactory)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
+ private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version";
private const string LastEventIdHeaderName = "Last-Event-ID";
+ ///
+ /// All protocol versions supported by this implementation.
+ /// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core.
+ ///
+ private static readonly HashSet s_supportedProtocolVersions =
+ [
+ "2024-11-05",
+ "2025-03-26",
+ "2025-06-18",
+ "2025-11-25",
+ ];
+
private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo();
private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo();
@@ -32,6 +45,12 @@ internal sealed class StreamableHttpHandler(
public async Task HandlePostRequestAsync(HttpContext context)
{
+ if (!ValidateProtocolVersionHeader(context, out var errorMessage))
+ {
+ await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
+ return;
+ }
+
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
// ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation,
// so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests,
@@ -74,6 +93,12 @@ internal sealed class StreamableHttpHandler(
public async Task HandleGetRequestAsync(HttpContext context)
{
+ if (!ValidateProtocolVersionHeader(context, out var errorMessage))
+ {
+ await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
+ return;
+ }
+
if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType))
{
await WriteJsonRpcErrorAsync(context,
@@ -171,6 +196,12 @@ internal sealed class StreamableHttpHandler(
public async Task HandleDeleteRequestAsync(HttpContext context)
{
+ if (!ValidateProtocolVersionHeader(context, out var errorMessage))
+ {
+ await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
+ return;
+ }
+
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (sessionManager.TryRemove(sessionId, out var session))
{
@@ -391,6 +422,24 @@ internal sealed class StreamableHttpHandler(
internal static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
+ ///
+ /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
+ /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec.
+ ///
+ private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage)
+ {
+ var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
+ if (!string.IsNullOrEmpty(protocolVersionHeader) &&
+ !s_supportedProtocolVersions.Contains(protocolVersionHeader))
+ {
+ errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.";
+ return false;
+ }
+
+ errorMessage = null;
+ return true;
+ }
+
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("application/json");
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index 9137218..1aa4446 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -31,7 +31,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// The latest version of the protocol supported by this implementation.
internal const string LatestProtocolVersion = "2025-11-25";
- /// All protocol versions supported by this implementation.
+ ///
+ /// All protocol versions supported by this implementation.
+ /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler.
+ ///
internal static readonly string[] SupportedProtocolVersions =
[
"2024-11-05",
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
index ca1c46b..a203896 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs
@@ -169,6 +169,54 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
+ [Theory]
+ [InlineData("invalid-version")]
+ [InlineData("9999-01-01")]
+ [InlineData("not-a-date")]
+ public async Task PostRequest_IsBadRequest_WithInvalidProtocolVersionHeader(string invalidVersion)
+ {
+ await StartAsync();
+
+ HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", invalidVersion);
+
+ using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task PostRequest_Succeeds_WithoutProtocolVersionHeader()
+ {
+ await StartAsync();
+
+ // No MCP-Protocol-Version header is set - this should be accepted for backwards compatibility
+ using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task PostRequest_Succeeds_WithValidProtocolVersionHeader()
+ {
+ await StartAsync();
+
+ HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-03-26");
+
+ using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetRequest_IsBadRequest_WithInvalidProtocolVersionHeader()
+ {
+ await StartAsync();
+
+ await CallInitializeAndValidateAsync();
+
+ HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "invalid-version");
+
+ using var response = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
[Fact]
public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
{
From b9678351443e611e760a3d8aba5de2b039d0fb55 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Feb 2026 21:47:31 -0800
Subject: [PATCH 063/320] Use TimeProvider in InMemoryMcpTaskStore for
deterministic testing (#1303)
---
.../Server/InMemoryMcpTaskStore.cs | 8 ++
.../Server/InMemoryMcpTaskStoreTests.cs | 102 ++++++++++++++----
2 files changed, 88 insertions(+), 22 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
index 27156e9..b2f9b05 100644
--- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
+++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
@@ -35,7 +35,11 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
private readonly TimeSpan? _defaultTtl;
private readonly TimeSpan? _maxTtl;
private readonly TimeSpan _pollInterval;
+#if MCP_TEST_TIME_PROVIDER
+ private readonly ITimer? _cleanupTimer;
+#else
private readonly Timer? _cleanupTimer;
+#endif
private readonly int _pageSize;
private readonly int? _maxTasks;
private readonly int? _maxTasksPerSession;
@@ -134,7 +138,11 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
cleanupInterval ??= TimeSpan.FromMinutes(1);
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
{
+#if MCP_TEST_TIME_PROVIDER
+ _cleanupTimer = _timeProvider.CreateTimer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value);
+#else
_cleanupTimer = new Timer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value);
+#endif
}
}
diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
index 5b9db45..56e9aac 100644
--- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
@@ -235,14 +235,24 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
[Fact]
public async Task UpdateTaskStatusAsync_UpdatesLastUpdatedAt()
{
- // Arrange
- using var store = new InMemoryMcpTaskStore();
+ // Arrange - Use FakeTimeProvider for deterministic testing
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ using var store = new TestInMemoryMcpTaskStore(
+ defaultTtl: null,
+ maxTtl: null,
+ pollInterval: null,
+ cleanupInterval: Timeout.InfiniteTimeSpan,
+ pageSize: 100,
+ maxTasks: null,
+ maxTasksPerSession: null,
+ timeProvider: fakeTime);
+
var metadata = new McpTaskMetadata();
var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken);
var originalTimestamp = task.LastUpdatedAt;
- // Wait a bit to ensure timestamp changes
- await Task.Delay(10, TestContext.Current.CancellationToken);
+ // Advance time to ensure timestamp changes
+ fakeTime.Advance(TimeSpan.FromMilliseconds(10));
// Act
await store.UpdateTaskStatusAsync(task.TaskId, McpTaskStatus.Working, null, null, TestContext.Current.CancellationToken);
@@ -460,16 +470,28 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
[Fact]
public async Task Dispose_StopsCleanupTimer()
{
- // Arrange
- var store = new InMemoryMcpTaskStore(cleanupInterval: TimeSpan.FromMilliseconds(100));
- var metadata = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(100) }; // Very short TTL
+ // Arrange - Use FakeTimeProvider for deterministic testing
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ var cleanupInterval = TimeSpan.FromMilliseconds(100);
+
+ var store = new TestInMemoryMcpTaskStore(
+ defaultTtl: null,
+ maxTtl: null,
+ pollInterval: null,
+ cleanupInterval: cleanupInterval,
+ pageSize: 100,
+ maxTasks: null,
+ maxTasksPerSession: null,
+ timeProvider: fakeTime);
+
+ var metadata = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(100) };
await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken);
// Act
store.Dispose();
- // Wait longer than cleanup interval
- await Task.Delay(300, TestContext.Current.CancellationToken);
+ // Advance time - timer should not fire after dispose
+ fakeTime.Advance(TimeSpan.FromTicks(cleanupInterval.Ticks * 3));
// Assert - Store should still be accessible after dispose (no exceptions)
// The cleanup timer should have stopped
@@ -479,17 +501,33 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
[Fact]
public async Task CleanupExpiredTasks_RemovesExpiredTasks()
{
- // Arrange
- using var store = new InMemoryMcpTaskStore(cleanupInterval: TimeSpan.FromMilliseconds(50));
- var metadata = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(100) }; // 100ms TTL
+ // Arrange - Use FakeTimeProvider for deterministic testing
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ var cleanupInterval = TimeSpan.FromMilliseconds(50);
+ var ttl = TimeSpan.FromMilliseconds(100);
+
+ using var store = new TestInMemoryMcpTaskStore(
+ defaultTtl: null,
+ maxTtl: null,
+ pollInterval: null,
+ cleanupInterval: cleanupInterval,
+ pageSize: 100,
+ maxTasks: null,
+ maxTasksPerSession: null,
+ timeProvider: fakeTime);
+
+ var metadata = new McpTaskMetadata { TimeToLive = ttl };
var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken);
// Verify task exists initially
var resultBefore = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(resultBefore.Tasks);
- // Wait for task to expire and cleanup timer to run (wait for at least 3 cleanup cycles)
- await Task.Delay(250, TestContext.Current.CancellationToken);
+ // Advance time past the TTL to make task expired
+ fakeTime.Advance(ttl + TimeSpan.FromMilliseconds(1));
+
+ // Trigger cleanup by advancing time past cleanup interval
+ fakeTime.Advance(cleanupInterval);
// Act - List tasks to verify cleanup happened
var resultAfter = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
@@ -787,8 +825,18 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
[Fact]
public async Task ListTasksAsync_ConsistentWithExpiredTasksRemovedBetweenPages()
{
- // Arrange - Use TTL of 1 second
- using var store = new InMemoryMcpTaskStore(defaultTtl: TimeSpan.FromSeconds(1), pageSize: 5, cleanupInterval: Timeout.InfiniteTimeSpan);
+ // Arrange - Use FakeTimeProvider for deterministic testing
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ var ttl = TimeSpan.FromSeconds(1);
+ using var store = new TestInMemoryMcpTaskStore(
+ defaultTtl: ttl,
+ maxTtl: null,
+ pollInterval: null,
+ cleanupInterval: Timeout.InfiniteTimeSpan,
+ pageSize: 5,
+ maxTasks: null,
+ maxTasksPerSession: null,
+ timeProvider: fakeTime);
// Create 15 tasks
for (int i = 0; i < 15; i++)
@@ -799,8 +847,8 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
// Act - Get first page immediately
var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
- // Wait for tasks to expire
- await Task.Delay(TimeSpan.FromSeconds(1.5), TestContext.Current.CancellationToken);
+ // Advance time past TTL to make tasks expire
+ fakeTime.Advance(ttl + TimeSpan.FromMilliseconds(500));
// Get second page after expiration
var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken);
@@ -1016,16 +1064,26 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
[Fact]
public async Task CreateTaskAsync_MaxTasksPerSession_ExcludesExpiredTasks()
{
- // Arrange - Short TTL and per-session limit of 1
+ // Arrange - Use FakeTimeProvider for deterministic testing
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
var shortTtl = TimeSpan.FromMilliseconds(50);
- using var store = new InMemoryMcpTaskStore(defaultTtl: shortTtl, maxTasksPerSession: 1);
+ using var store = new TestInMemoryMcpTaskStore(
+ defaultTtl: shortTtl,
+ maxTtl: null,
+ pollInterval: null,
+ cleanupInterval: Timeout.InfiniteTimeSpan,
+ pageSize: 100,
+ maxTasks: null,
+ maxTasksPerSession: 1,
+ timeProvider: fakeTime);
+
var metadata = new McpTaskMetadata();
// Create first task
await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken);
- // Wait for it to expire
- await Task.Delay(100, TestContext.Current.CancellationToken);
+ // Advance time past TTL to make the first task expire
+ fakeTime.Advance(shortTtl + TimeSpan.FromMilliseconds(1));
// Should be able to create another task since the first one expired
var task2 = await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken);
From 231e013160570f92c3b8ca92af066f69cff4b298 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Feb 2026 21:56:23 -0800
Subject: [PATCH 064/320] Reduce flakiness in long-running stateless HTTP tool
timeout test (#1300)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
---
.../MapMcpTests.cs | 59 ++++++++++++-------
1 file changed, 37 insertions(+), 22 deletions(-)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
index a032494..bee17be 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
@@ -245,34 +245,49 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
- // Create a custom HttpClient with a very short timeout (1 second)
- // The tool will take 2 seconds to complete
- using var shortTimeoutClient = new HttpClient(SocketsHttpHandler)
+ // Retry a couple of times to reduce occasional flakiness on low-resource machines.
+ // If the server regresses to flushing only after tool completion, each attempt should still fail
+ // because HttpClient timeout (1 second) is below the tool duration (2 seconds).
+ for (var attempt = 0; attempt < 3; attempt++)
{
- BaseAddress = new Uri("http://localhost:5000/"),
- Timeout = TimeSpan.FromSeconds(1)
- };
+ try
+ {
+ // Create a custom HttpClient with a very short timeout (1 second)
+ // The tool will take 2 seconds to complete
+ using var shortTimeoutClient = new HttpClient(SocketsHttpHandler, disposeHandler: false)
+ {
+ BaseAddress = new Uri("http://localhost:5000/"),
+ Timeout = TimeSpan.FromSeconds(1)
+ };
- var path = UseStreamableHttp ? "/" : "/sse";
- var transportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse;
+ var path = UseStreamableHttp ? "/" : "/sse";
+ var transportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse;
- await using var transport = new HttpClientTransport(new()
- {
- Endpoint = new($"http://localhost:5000{path}"),
- TransportMode = transportMode,
- }, shortTimeoutClient, LoggerFactory);
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new($"http://localhost:5000{path}"),
+ TransportMode = transportMode,
+ }, shortTimeoutClient, LoggerFactory);
- await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+ await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
- // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout
- // because the response stream is flushed immediately after receiving the request
- var response = await mcpClient.CallToolAsync(
- "long_running_operation",
- new Dictionary() { ["durationMs"] = 2000 },
- cancellationToken: TestContext.Current.CancellationToken);
+ // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout
+ // because the response stream is flushed immediately after receiving the request
+ var response = await mcpClient.CallToolAsync(
+ "long_running_operation",
+ new Dictionary() { ["durationMs"] = 2000 },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var content = Assert.Single(response.Content.OfType());
+ Assert.Equal("Operation completed after 2000ms", content.Text);
+ return;
+ }
+ catch (OperationCanceledException) when (attempt < 2)
+ {
+ // Retry intermittent timeout-related failures on slow CI machines.
+ }
+ }
- var content = Assert.Single(response.Content.OfType());
- Assert.Equal("Operation completed after 2000ms", content.Text);
}
private ClaimsPrincipal CreateUser(string name)
From cc6ef5b926505f7e65d6726f4bf2ae1132ec69f4 Mon Sep 17 00:00:00 2001
From: Mackinnon Buck
Date: Thu, 19 Feb 2026 07:27:30 -0800
Subject: [PATCH 065/320] Hide experimental types from external source
generators using internal property pattern (#1301)
---
ModelContextProtocol.slnx | 1 +
.../Protocol/CallToolRequestParams.cs | 13 +-
.../Protocol/CallToolResult.cs | 13 +-
.../Protocol/ClientCapabilities.cs | 13 +-
.../Protocol/CreateMessageRequestParams.cs | 13 +-
.../Protocol/ElicitRequestParams.cs | 12 +-
.../Protocol/ServerCapabilities.cs | 13 +-
.../Protocol/Tool.cs | 11 +-
.../ExperimentalPropertyRegressionContext.cs | 17 +++
...tocol.ExperimentalApiRegressionTest.csproj | 29 +++++
.../ExperimentalInternalPropertyTests.cs | 69 ++++++++++
.../ExperimentalPropertySerializationTests.cs | 118 ++++++++++++++++++
12 files changed, 315 insertions(+), 7 deletions(-)
create mode 100644 tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs
create mode 100644 tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj
create mode 100644 tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs
create mode 100644 tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs
diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx
index 3036f30..1090c53 100644
--- a/ModelContextProtocol.slnx
+++ b/ModelContextProtocol.slnx
@@ -78,5 +78,6 @@
+
diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs
index fa6939d..8267cd0 100644
--- a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -33,6 +34,16 @@ public sealed class CallToolRequestParams : RequestParams
/// When present, indicates that the requestor wants this operation executed as a task.
/// The receiver must support task augmentation for this specific request type.
///
+ [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
+ [JsonIgnore]
+ public McpTaskMetadata? Task
+ {
+ get => TaskCore;
+ set => TaskCore = value;
+ }
+
+ // See ExperimentalInternalPropertyTests.cs before modifying this property.
+ [JsonInclude]
[JsonPropertyName("task")]
- public McpTaskMetadata? Task { get; set; }
+ internal McpTaskMetadata? TaskCore { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
index e760d2f..cd4c1ab 100644
--- a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -72,6 +73,16 @@ public sealed class CallToolResult : Result
/// (, , ) may not be populated.
/// The actual tool result can be retrieved later via tasks/result.
///
+ [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
+ [JsonIgnore]
+ public McpTask? Task
+ {
+ get => TaskCore;
+ set => TaskCore = value;
+ }
+
+ // See ExperimentalInternalPropertyTests.cs before modifying this property.
+ [JsonInclude]
[JsonPropertyName("task")]
- public McpTask? Task { get; set; }
+ internal McpTask? TaskCore { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs
index cb85ef5..9841e3d 100644
--- a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs
@@ -1,4 +1,5 @@
using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
@@ -80,6 +81,16 @@ public sealed class ClientCapabilities
/// See for details on configuring which operations support tasks.
///
///
+ [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
+ [JsonIgnore]
+ public McpTasksCapability? Tasks
+ {
+ get => TasksCore;
+ set => TasksCore = value;
+ }
+
+ // See ExperimentalInternalPropertyTests.cs before modifying this property.
+ [JsonInclude]
[JsonPropertyName("tasks")]
- public McpTasksCapability? Tasks { get; set; }
+ internal McpTasksCapability? TasksCore { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
index 2c029ba..711f1fc 100644
--- a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -160,6 +161,16 @@ public sealed class CreateMessageRequestParams : RequestParams
/// When present, indicates that the requestor wants this operation executed as a task.
/// The receiver must support task augmentation for this specific request type.
///
+ [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
+ [JsonIgnore]
+ public McpTaskMetadata? Task
+ {
+ get => TaskCore;
+ set => TaskCore = value;
+ }
+
+ // See ExperimentalInternalPropertyTests.cs before modifying this property.
+ [JsonInclude]
[JsonPropertyName("task")]
- public McpTaskMetadata? Task { get; set; }
+ internal McpTaskMetadata? TaskCore { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
index 91cbb30..9210085 100644
--- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
@@ -99,8 +99,18 @@ public sealed class ElicitRequestParams : RequestParams
/// When present, indicates that the requestor wants this operation executed as a task.
/// The receiver must support task augmentation for this specific request type.
///
+ [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
+ [JsonIgnore]
+ public McpTaskMetadata? Task
+ {
+ get => TaskCore;
+ set => TaskCore = value;
+ }
+
+ // See ExperimentalInternalPropertyTests.cs before modifying this property.
+ [JsonInclude]
[JsonPropertyName("task")]
- public McpTaskMetadata? Task { get; set; }
+ internal McpTaskMetadata? TaskCore { get; set; }
/// Represents a request schema used in a form mode elicitation request.
public sealed class RequestSchema
diff --git a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs
index 4998196..d4e5565 100644
--- a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs
@@ -1,4 +1,5 @@
using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;
@@ -79,6 +80,16 @@ public sealed class ServerCapabilities
/// See for details on configuring which operations support tasks.
///
///
+ [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
+ [JsonIgnore]
+ public McpTasksCapability? Tasks
+ {
+ get => TasksCore;
+ set => TasksCore = value;
+ }
+
+ // See ExperimentalInternalPropertyTests.cs before modifying this property.
+ [JsonInclude]
[JsonPropertyName("tasks")]
- public McpTasksCapability? Tasks { get; set; }
+ internal McpTasksCapability? TasksCore { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs
index 4c14756..dfce0ea 100644
--- a/src/ModelContextProtocol.Core/Protocol/Tool.cs
+++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs
@@ -128,8 +128,17 @@ public sealed class Tool : IBaseMetadata
/// regarding task augmentation support. See for details.
///
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
+ [JsonIgnore]
+ public ToolExecution? Execution
+ {
+ get => ExecutionCore;
+ set => ExecutionCore = value;
+ }
+
+ // See ExperimentalInternalPropertyTests.cs before modifying this property.
+ [JsonInclude]
[JsonPropertyName("execution")]
- public ToolExecution? Execution { get; set; }
+ internal ToolExecution? ExecutionCore { get; set; }
///
/// Gets or sets an optional list of icons for this tool.
diff --git a/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs
new file mode 100644
index 0000000..d300368
--- /dev/null
+++ b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Protocol;
+
+namespace ModelContextProtocol.ExperimentalApiRegressionTest;
+
+///
+/// This file validates that the System.Text.Json source generator does not produce
+/// MCPEXP001 diagnostics for MCP protocol types with experimental properties.
+///
+[JsonSerializable(typeof(Tool))]
+[JsonSerializable(typeof(ServerCapabilities))]
+[JsonSerializable(typeof(ClientCapabilities))]
+[JsonSerializable(typeof(CallToolResult))]
+[JsonSerializable(typeof(CallToolRequestParams))]
+[JsonSerializable(typeof(CreateMessageRequestParams))]
+[JsonSerializable(typeof(ElicitRequestParams))]
+internal partial class ExperimentalPropertyRegressionContext : JsonSerializerContext;
diff --git a/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj
new file mode 100644
index 0000000..fb24235
--- /dev/null
+++ b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0;net9.0;net8.0
+ enable
+ enable
+ false
+
+
+ $(NoWarn.Replace('MCPEXP001',''))
+
+
+ $(NoWarn);SYSLIB1038
+
+
+
+
+
+
+
diff --git a/tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs
new file mode 100644
index 0000000..17e18dc
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs
@@ -0,0 +1,69 @@
+using System.Reflection;
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Protocol;
+
+namespace ModelContextProtocol.Tests;
+
+///
+/// Validates the internal property pattern used for experimental MCP properties.
+///
+///
+/// Experimental properties on stable protocol types must use an internal serialization
+/// property to hide the experimental type from external source generators. The public
+/// property is marked [Experimental][JsonIgnore] and delegates to an internal *Core
+/// property marked [JsonInclude][JsonPropertyName].
+///
+public class ExperimentalInternalPropertyTests
+{
+ [Fact]
+ public void ExperimentalProperties_MustBeHiddenFromSourceGenerator()
+ {
+ // [Experimental] properties on stable protocol types must use the internal property
+ // pattern so the STJ source generator does not reference experimental types in
+ // generated code (which would trigger MCPEXP001 for consumers).
+ //
+ // Required pattern:
+ // 1. Mark the public property [Experimental][JsonIgnore]
+ // 2. Add an internal *Core property with [JsonInclude][JsonPropertyName]
+ // that the public property delegates to
+ //
+ // To stabilize:
+ // 1. Remove [Experimental] and [JsonIgnore] from the public property
+ // 2. Add [JsonPropertyName] to the public property
+ // 3. Convert to auto-property
+ // 4. Remove the internal *Core property
+
+ foreach (var (type, prop) in GetExperimentalPropertiesOnStableTypes())
+ {
+ Assert.True(
+ prop.GetCustomAttribute() is not null,
+ $"{type.Name}.{prop.Name} is [Experimental] but missing [JsonIgnore].");
+
+ Assert.True(
+ prop.GetCustomAttribute() is null,
+ $"{type.Name}.{prop.Name} is [Experimental] and must not have [JsonPropertyName].");
+ }
+ }
+
+ private static IEnumerable<(Type Type, PropertyInfo Property)> GetExperimentalPropertiesOnStableTypes()
+ {
+ var protocolTypes = typeof(Tool).Assembly.GetTypes()
+ .Where(t => t.Namespace == "ModelContextProtocol.Protocol" && t.IsClass && !t.IsAbstract);
+
+ foreach (var type in protocolTypes)
+ {
+ if (type.GetCustomAttributes().Any(a => a.GetType().Name == "ExperimentalAttribute"))
+ {
+ continue;
+ }
+
+ foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+ {
+ if (prop.GetCustomAttributes().Any(a => a.GetType().Name == "ExperimentalAttribute"))
+ {
+ yield return (type, prop);
+ }
+ }
+ }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs
new file mode 100644
index 0000000..d68902e
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs
@@ -0,0 +1,118 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Protocol;
+
+namespace ModelContextProtocol.Tests;
+
+///
+/// Validates that the internal property pattern used for experimental properties
+/// produces the expected serialization behavior for SDK consumers using source generators.
+///
+///
+///
+/// Experimental properties (e.g. , )
+/// use an internal *Core property for serialization. A consumer's source-generated
+/// cannot see internal members, so experimental data is
+/// silently dropped unless the consumer chains the SDK's resolver into their options.
+///
+///
+/// These tests depend on and
+/// being experimental. When those APIs stabilize, update these tests to reference whatever
+/// experimental properties exist at that time, or remove them entirely if no experimental
+/// APIs remain.
+///
+///
+public class ExperimentalPropertySerializationTests
+{
+ [Fact]
+ public void ExperimentalProperties_Dropped_WithConsumerContextOnly()
+ {
+ var options = new JsonSerializerOptions
+ {
+ TypeInfoResolverChain = { ConsumerJsonContext.Default }
+ };
+
+ var tool = new Tool
+ {
+ Name = "test-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ };
+
+ string json = JsonSerializer.Serialize(tool, options);
+ Assert.DoesNotContain("\"execution\"", json);
+ Assert.Contains("\"name\"", json);
+ }
+
+ [Fact]
+ public void ExperimentalProperties_IgnoredOnDeserialize_WithConsumerContextOnly()
+ {
+ string json = JsonSerializer.Serialize(
+ new Tool
+ {
+ Name = "test-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ },
+ McpJsonUtilities.DefaultOptions);
+ Assert.Contains("\"execution\"", json);
+
+ var options = new JsonSerializerOptions
+ {
+ TypeInfoResolverChain = { ConsumerJsonContext.Default }
+ };
+ var deserialized = JsonSerializer.Deserialize(json, options)!;
+ Assert.Equal("test-tool", deserialized.Name);
+ Assert.Null(deserialized.Execution);
+ }
+
+ [Fact]
+ public void ExperimentalProperties_RoundTrip_WhenSdkResolverIsChained()
+ {
+ var options = new JsonSerializerOptions
+ {
+ TypeInfoResolverChain =
+ {
+ McpJsonUtilities.DefaultOptions.TypeInfoResolver!,
+ ConsumerJsonContext.Default,
+ }
+ };
+
+ var tool = new Tool
+ {
+ Name = "test-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ };
+
+ string json = JsonSerializer.Serialize(tool, options);
+ Assert.Contains("\"execution\"", json);
+ Assert.Contains("\"name\"", json);
+
+ var deserialized = JsonSerializer.Deserialize(json, options)!;
+ Assert.Equal("test-tool", deserialized.Name);
+ Assert.NotNull(deserialized.Execution);
+ Assert.Equal(ToolTaskSupport.Optional, deserialized.Execution.TaskSupport);
+ }
+
+ [Fact]
+ public void ExperimentalProperties_RoundTrip_WithDefaultOptions()
+ {
+ var capabilities = new ServerCapabilities
+ {
+ Tasks = new McpTasksCapability()
+ };
+
+ string json = JsonSerializer.Serialize(capabilities, McpJsonUtilities.DefaultOptions);
+ Assert.Contains("\"tasks\"", json);
+
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!;
+ Assert.NotNull(deserialized.Tasks);
+ }
+}
+
+[JsonSerializable(typeof(Tool))]
+[JsonSerializable(typeof(ServerCapabilities))]
+[JsonSerializable(typeof(ClientCapabilities))]
+[JsonSerializable(typeof(CallToolResult))]
+[JsonSerializable(typeof(CallToolRequestParams))]
+[JsonSerializable(typeof(CreateMessageRequestParams))]
+[JsonSerializable(typeof(ElicitRequestParams))]
+internal partial class ConsumerJsonContext : JsonSerializerContext;
From a3ce9e9606d4e6315d0038828047a6c29472acba Mon Sep 17 00:00:00 2001
From: Mackinnon Buck
Date: Thu, 19 Feb 2026 11:44:35 -0800
Subject: [PATCH 066/320] Add docs for working with experimental APIs (#1307)
Co-authored-by: Jeff Handley
---
docs/concepts/tasks/tasks.md | 2 +-
docs/experimental.md | 70 ++++++++++++++++++++++++++++++++++++
docs/toc.yml | 2 ++
3 files changed, 73 insertions(+), 1 deletion(-)
create mode 100644 docs/experimental.md
diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md
index 9b67e11..1b312d9 100644
--- a/docs/concepts/tasks/tasks.md
+++ b/docs/concepts/tasks/tasks.md
@@ -8,7 +8,7 @@ uid: tasks
# MCP Tasks
> [!WARNING]
-> Tasks are an **experimental feature** in the MCP specification (version 2025-11-25). The API may change in future releases.
+> Tasks are an **experimental feature** in the MCP specification (version 2025-11-25). The API may change in future releases. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs.
The Model Context Protocol (MCP) supports [task-based execution] for long-running operations. Tasks enable a "call-now, fetch-later" pattern where clients can initiate operations that may take significant time to complete, then poll for status and retrieve results when ready.
diff --git a/docs/experimental.md b/docs/experimental.md
new file mode 100644
index 0000000..1ad75a9
--- /dev/null
+++ b/docs/experimental.md
@@ -0,0 +1,70 @@
+---
+title: Experimental APIs
+author: MackinnonBuck
+description: Working with experimental APIs in the MCP C# SDK
+uid: experimental
+---
+
+The Model Context Protocol C# SDK uses the [`[Experimental]`](https://learn.microsoft.com/dotnet/api/system.diagnostics.codeanalysis.experimentalattribute) attribute to mark APIs that are still in development and may change without notice. For more details on the SDK's versioning policy around experimental APIs, see the [Versioning](versioning.md) documentation.
+
+## Suppressing experimental diagnostics
+
+When you use an experimental API, the compiler produces a diagnostic (e.g., `MCPEXP001`) to ensure you're aware the API may change. If you want to use the API, suppress the diagnostic in one of these ways:
+
+### Project-wide suppression
+
+Add the diagnostic ID to `` in your project file:
+
+```xml
+
+ $(NoWarn);MCPEXP001
+
+```
+
+### Per-call suppression
+
+Use `#pragma warning disable` around specific call sites:
+
+```csharp
+#pragma warning disable MCPEXP001 // The Tasks feature is experimental per the MCP specification and is subject to change.
+tool.Execution = new ToolExecution { ... };
+#pragma warning restore MCPEXP001
+```
+
+For a full list of experimental diagnostic IDs and their descriptions, see the [list of diagnostics](list-of-diagnostics.md#experimental-apis).
+
+## Serialization behavior
+
+Experimental properties on protocol types are fully serialized and deserialized when using the SDK's built-in serialization via . This means experimental data is transmitted on the wire even if your application code doesn't directly interact with it, preserving protocol compatibility.
+
+The behavior of experimental properties differs depending on whether you use [reflection-based or source-generated](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) serialization:
+
+- **Reflection-based serialization** (the default when no `JsonSerializerContext` is used): Experimental properties are included. No special configuration is needed.
+- **Source-generated serialization** (using a custom `JsonSerializerContext`): Experimental properties are **not** included in your context's serialization contract. This is by design, as it protects your compiled code against binary breaking changes to experimental APIs.
+
+This means that switching between reflection-based and source-generated serialization can silently change which properties are serialized. To avoid this, source-generation users should configure a `TypeInfoResolverChain` as described below.
+
+### Custom `JsonSerializerContext`
+
+If you define your own `JsonSerializerContext` that includes MCP protocol types, configure a `TypeInfoResolverChain` so the SDK's resolver handles MCP types:
+
+```csharp
+using ModelContextProtocol;
+
+JsonSerializerOptions options = new()
+{
+ TypeInfoResolverChain =
+ {
+ McpJsonUtilities.DefaultOptions.TypeInfoResolver!,
+ MyCustomContext.Default,
+ }
+};
+```
+
+By placing the SDK's resolver first, MCP types are serialized using the SDK's contract (which includes experimental properties), while your custom context handles your own types. This is recommended even if you aren't currently using experimental APIs, since it ensures your serialization configuration remains correct as new experimental properties are introduced or as you adopt experimental features in the future.
+
+## See also
+
+- [Versioning](versioning.md)
+- [List of diagnostics](list-of-diagnostics.md#experimental-apis)
+- [Tasks](concepts/tasks/tasks.md) (an experimental feature)
diff --git a/docs/toc.yml b/docs/toc.yml
index 84cf4de..9b70938 100644
--- a/docs/toc.yml
+++ b/docs/toc.yml
@@ -5,5 +5,7 @@ items:
href: api/ModelContextProtocol.yml
- name: Versioning
href: versioning.md
+- name: Experimental APIs
+ href: experimental.md
- name: GitHub
href: https://github.com/ModelContextProtocol/csharp-sdk
From 80b1ad21d71b918906f741b532c29ef40dfd98be Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 14:08:05 -0800
Subject: [PATCH 067/320] Use ReadOnlyMemory for binary data to eliminate
UTF-16 transcoding (#1070)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: ericstj <8918108+ericstj@users.noreply.github.com>
Co-authored-by: Eric StJohn
---
.../Resources/SimpleResourceType.cs | 3 +-
.../Tools/AnnotatedMessageTool.cs | 3 +-
src/Common/EncodingUtilities.cs | 59 ++++++++
.../System/Text/EncodingExtensions.cs | 50 +++++++
.../ServerSentEvents/SseEventWriterHelpers.cs | 8 -
.../AIContentExtensions.cs | 16 +-
.../ModelContextProtocol.Core.csproj | 1 +
.../Protocol/BlobResourceContents.cs | 69 ++++++++-
.../Protocol/ContentBlock.cs | 137 +++++++++++++++++-
.../Protocol/DebuggerDisplayHelper.cs | 21 +++
.../Protocol/ResourceContents.cs | 9 +-
.../Server/AIFunctionMcpServerResource.cs | 10 +-
.../ModelContextProtocol.csproj | 1 +
tests/Directory.Build.props | 1 +
.../HttpServerIntegrationTests.cs | 2 +-
.../Prompts/ConformancePrompts.cs | 2 +-
.../Resources/ConformanceResources.cs | 2 +-
.../Tools/ConformanceTools.cs | 6 +-
.../Program.cs | 7 +-
.../Program.cs | 9 +-
.../AIContentExtensionsTests.cs | 4 +-
.../Client/McpClientTests.cs | 11 +-
.../Client/McpClientToolTests.cs | 8 +-
.../ClientIntegrationTests.cs | 2 +-
.../Protocol/ContentBlockTests.cs | 4 +-
.../Protocol/CreateMessageResultTests.cs | 6 +-
.../Protocol/PromptMessageTests.cs | 4 +-
.../Protocol/ReadResourceResultTests.cs | 4 +-
.../Protocol/ResourceContentsTests.cs | 18 +--
.../Protocol/UnknownPropertiesTests.cs | 2 +-
.../Server/McpServerResourceTests.cs | 8 +-
.../Server/McpServerToolTests.cs | 16 +-
32 files changed, 400 insertions(+), 103 deletions(-)
create mode 100644 src/Common/EncodingUtilities.cs
create mode 100644 src/Common/Polyfills/System/Text/EncodingExtensions.cs
diff --git a/samples/EverythingServer/Resources/SimpleResourceType.cs b/samples/EverythingServer/Resources/SimpleResourceType.cs
index 15d055d..eafc6c6 100644
--- a/samples/EverythingServer/Resources/SimpleResourceType.cs
+++ b/samples/EverythingServer/Resources/SimpleResourceType.cs
@@ -1,6 +1,7 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
+using System.Text;
namespace EverythingServer.Resources;
@@ -31,7 +32,7 @@ public class SimpleResourceType
} :
new BlobResourceContents
{
- Blob = resource.Description!,
+ Blob = Encoding.UTF8.GetBytes(resource.Description!),
MimeType = resource.MimeType,
Uri = resource.Uri,
};
diff --git a/samples/EverythingServer/Tools/AnnotatedMessageTool.cs b/samples/EverythingServer/Tools/AnnotatedMessageTool.cs
index 7f92d0a..4ebdf15 100644
--- a/samples/EverythingServer/Tools/AnnotatedMessageTool.cs
+++ b/samples/EverythingServer/Tools/AnnotatedMessageTool.cs
@@ -1,6 +1,7 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
+using System.Text;
namespace EverythingServer.Tools;
@@ -41,7 +42,7 @@ public class AnnotatedMessageTool
{
contents.Add(new ImageContentBlock
{
- Data = TinyImageTool.MCP_TINY_IMAGE.Split(",").Last(),
+ Data = Encoding.UTF8.GetBytes(TinyImageTool.MCP_TINY_IMAGE.Split(",").Last()),
MimeType = "image/png",
Annotations = new() { Audience = [Role.User], Priority = 0.5f }
});
diff --git a/src/Common/EncodingUtilities.cs b/src/Common/EncodingUtilities.cs
new file mode 100644
index 0000000..5012788
--- /dev/null
+++ b/src/Common/EncodingUtilities.cs
@@ -0,0 +1,59 @@
+using System.Buffers;
+using System.Buffers.Text;
+using System.Diagnostics;
+using System.Text;
+
+namespace ModelContextProtocol;
+
+/// Provides helper methods for encoding operations.
+internal static class EncodingUtilities
+{
+ ///
+ /// Converts UTF-16 characters to UTF-8 bytes without intermediate string allocations.
+ ///
+ /// The UTF-16 character span to convert.
+ /// A byte array containing the UTF-8 encoded bytes.
+ public static byte[] GetUtf8Bytes(ReadOnlySpan utf16)
+ {
+ byte[] bytes = new byte[Encoding.UTF8.GetByteCount(utf16)];
+ Encoding.UTF8.GetBytes(utf16, bytes);
+ return bytes;
+ }
+
+ ///
+ /// Encodes binary data to base64-encoded UTF-8 bytes.
+ ///
+ /// The binary data to encode.
+ /// A ReadOnlyMemory containing the base64-encoded UTF-8 bytes.
+ public static ReadOnlyMemory EncodeToBase64Utf8(ReadOnlyMemory data)
+ {
+ int maxLength = Base64.GetMaxEncodedToUtf8Length(data.Length);
+ byte[] buffer = new byte[maxLength];
+ OperationStatus status = Base64.EncodeToUtf8(data.Span, buffer, out _, out int bytesWritten);
+ Debug.Assert(status == OperationStatus.Done, "Base64 encoding should succeed for valid input data");
+ Debug.Assert(bytesWritten == buffer.Length, "Base64 encoding should always produce the same length as the max length");
+ return buffer.AsMemory(0, bytesWritten);
+ }
+
+ ///
+ /// Decodes base64-encoded UTF-8 bytes to binary data.
+ ///
+ /// The base64-encoded UTF-8 bytes to decode.
+ /// A ReadOnlyMemory containing the decoded binary data.
+ /// The input is not valid base64 data.
+ public static ReadOnlyMemory DecodeFromBase64Utf8(ReadOnlyMemory base64Data)
+ {
+ int maxLength = Base64.GetMaxDecodedFromUtf8Length(base64Data.Length);
+ byte[] buffer = new byte[maxLength];
+ if (Base64.DecodeFromUtf8(base64Data.Span, buffer, out _, out int bytesWritten) == OperationStatus.Done)
+ {
+ // Base64 decoding may produce fewer bytes than the max length, due to whitespace anywhere in the string or padding.
+ Debug.Assert(bytesWritten <= buffer.Length, "Base64 decoding should never produce more bytes than the max length");
+ return buffer.AsMemory(0, bytesWritten);
+ }
+ else
+ {
+ throw new FormatException("Invalid base64 data");
+ }
+ }
+}
diff --git a/src/Common/Polyfills/System/Text/EncodingExtensions.cs b/src/Common/Polyfills/System/Text/EncodingExtensions.cs
new file mode 100644
index 0000000..65369ad
--- /dev/null
+++ b/src/Common/Polyfills/System/Text/EncodingExtensions.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if !NET
+
+namespace System.Text;
+
+internal static class EncodingExtensions
+{
+ ///
+ /// Gets the number of bytes required to encode the specified characters.
+ ///
+ public static int GetByteCount(this Encoding encoding, ReadOnlySpan chars)
+ {
+ if (chars.IsEmpty)
+ {
+ return 0;
+ }
+
+ unsafe
+ {
+ fixed (char* charsPtr = chars)
+ {
+ return encoding.GetByteCount(charsPtr, chars.Length);
+ }
+ }
+ }
+
+ ///
+ /// Encodes the specified characters into the specified byte span.
+ ///
+ public static int GetBytes(this Encoding encoding, ReadOnlySpan chars, Span bytes)
+ {
+ if (chars.IsEmpty)
+ {
+ return 0;
+ }
+
+ unsafe
+ {
+ fixed (char* charsPtr = chars)
+ fixed (byte* bytesPtr = bytes)
+ {
+ return encoding.GetBytes(charsPtr, chars.Length, bytesPtr, bytes.Length);
+ }
+ }
+ }
+}
+
+#endif
diff --git a/src/Common/ServerSentEvents/SseEventWriterHelpers.cs b/src/Common/ServerSentEvents/SseEventWriterHelpers.cs
index 53e315d..57fc17a 100644
--- a/src/Common/ServerSentEvents/SseEventWriterHelpers.cs
+++ b/src/Common/ServerSentEvents/SseEventWriterHelpers.cs
@@ -47,20 +47,12 @@ internal static class SseEventWriterHelpers
return;
}
-#if NET
int maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length);
Span buffer = writer.GetSpan(maxByteCount);
Debug.Assert(buffer.Length >= maxByteCount);
int bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
writer.Advance(bytesWritten);
-#else
- // netstandard2.0 doesn't have the Span overload of GetBytes
- byte[] bytes = Encoding.UTF8.GetBytes(value.ToString());
- Span buffer = writer.GetSpan(bytes.Length);
- bytes.AsSpan().CopyTo(buffer);
- writer.Advance(bytes.Length);
-#endif
}
public static bool ContainsLineBreaks(this ReadOnlySpan text) =>
diff --git a/src/ModelContextProtocol.Core/AIContentExtensions.cs b/src/ModelContextProtocol.Core/AIContentExtensions.cs
index 03f9642..a07a22b 100644
--- a/src/ModelContextProtocol.Core/AIContentExtensions.cs
+++ b/src/ModelContextProtocol.Core/AIContentExtensions.cs
@@ -1,9 +1,7 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
-#if !NET
-using System.Runtime.InteropServices;
-#endif
+using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -281,9 +279,9 @@ public static class AIContentExtensions
{
TextContentBlock textContent => new TextContent(textContent.Text),
- ImageContentBlock imageContent => new DataContent(Convert.FromBase64String(imageContent.Data), imageContent.MimeType),
+ ImageContentBlock imageContent => new DataContent(imageContent.DecodedData, imageContent.MimeType),
- AudioContentBlock audioContent => new DataContent(Convert.FromBase64String(audioContent.Data), audioContent.MimeType),
+ AudioContentBlock audioContent => new DataContent(audioContent.DecodedData, audioContent.MimeType),
EmbeddedResourceBlock resourceContent => resourceContent.Resource.ToAIContent(),
@@ -324,7 +322,7 @@ public static class AIContentExtensions
AIContent ac = content switch
{
- BlobResourceContents blobResource => new DataContent(Convert.FromBase64String(blobResource.Blob), blobResource.MimeType ?? "application/octet-stream"),
+ BlobResourceContents blobResource => new DataContent(blobResource.DecodedData, blobResource.MimeType ?? "application/octet-stream"),
TextResourceContents textResource => new TextContent(textResource.Text),
_ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.")
};
@@ -401,13 +399,13 @@ public static class AIContentExtensions
DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentBlock
{
- Data = dataContent.Base64Data.ToString(),
+ Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
},
DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
{
- Data = dataContent.Base64Data.ToString(),
+ Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
},
@@ -415,7 +413,7 @@ public static class AIContentExtensions
{
Resource = new BlobResourceContents
{
- Blob = dataContent.Base64Data.ToString(),
+ Blob = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
Uri = string.Empty,
}
diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
index bc4ae4c..05e732c 100644
--- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
+++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
@@ -25,6 +25,7 @@
+
diff --git a/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs b/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs
index 904e71b..859d45a 100644
--- a/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs
+++ b/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs
@@ -1,4 +1,7 @@
+using System.Buffers;
+using System.Buffers.Text;
using System.Diagnostics;
+using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
@@ -9,7 +12,7 @@ namespace ModelContextProtocol.Protocol;
///
///
/// is used when binary data needs to be exchanged through
-/// the Model Context Protocol. The binary data is represented as a base64-encoded string
+/// the Model Context Protocol. The binary data is represented as base64-encoded UTF-8 bytes
/// in the property.
///
///
@@ -24,18 +27,76 @@ namespace ModelContextProtocol.Protocol;
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class BlobResourceContents : ResourceContents
{
+ private ReadOnlyMemory? _decodedData;
+ private ReadOnlyMemory _blob;
+
///
- /// Gets or sets the base64-encoded string representing the binary data of the item.
+ /// Creates an from raw data.
///
+ /// The raw unencoded data.
+ /// The URI of the blob resource.
+ /// The optional MIME type of the data.
+ /// A new instance.
+ ///
+ public static BlobResourceContents FromBytes(ReadOnlyMemory bytes, string uri, string? mimeType = null)
+ {
+ ReadOnlyMemory blob = EncodingUtilities.EncodeToBase64Utf8(bytes);
+
+ return new()
+ {
+ _decodedData = bytes,
+ Blob = blob,
+ MimeType = mimeType,
+ Uri = uri
+ };
+ }
+
+ ///
+ /// Gets or sets the base64-encoded UTF-8 bytes representing the binary data of the item.
+ ///
+ ///
+ /// Setting this value will invalidate any cached value of .
+ ///
[JsonPropertyName("blob")]
- public required string Blob { get; set; }
+ public required ReadOnlyMemory Blob
+ {
+ get => _blob;
+ set
+ {
+ _blob = value;
+ _decodedData = null; // Invalidate cache
+ }
+ }
+
+ ///
+ /// Gets the decoded data represented by .
+ ///
+ ///
+ ///
+ /// When getting, this member will decode the value in and cache the result.
+ /// Subsequent accesses return the cached value unless is modified.
+ ///
+ ///
+ [JsonIgnore]
+ public ReadOnlyMemory DecodedData
+ {
+ get
+ {
+ if (_decodedData is null)
+ {
+ _decodedData = EncodingUtilities.DecodeFromBase64Utf8(Blob);
+ }
+
+ return _decodedData.Value;
+ }
+ }
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay
{
get
{
- string lengthDisplay = DebuggerDisplayHelper.GetBase64LengthDisplay(Blob);
+ string lengthDisplay = _decodedData is null ? DebuggerDisplayHelper.GetBase64LengthDisplay(Blob) : $"{DecodedData.Length} bytes";
string mimeInfo = MimeType is not null ? $", MimeType = {MimeType}" : "";
return $"Uri = \"{Uri}\"{mimeInfo}, Length = {lengthDisplay}";
}
diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
index c854787..ff3d15a 100644
--- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
@@ -1,6 +1,9 @@
+using System.Buffers;
+using System.Buffers.Text;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
+using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -87,7 +90,7 @@ public abstract class ContentBlock
string? type = null;
string? text = null;
string? name = null;
- string? data = null;
+ ReadOnlyMemory? data = null;
string? mimeType = null;
string? uri = null;
string? description = null;
@@ -128,7 +131,7 @@ public abstract class ContentBlock
break;
case "data":
- data = reader.GetString();
+ data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
break;
case "mimeType":
@@ -279,12 +282,12 @@ public abstract class ContentBlock
break;
case ImageContentBlock imageContent:
- writer.WriteString("data", imageContent.Data);
+ writer.WriteString("data", imageContent.Data.Span);
writer.WriteString("mimeType", imageContent.MimeType);
break;
case AudioContentBlock audioContent:
- writer.WriteString("data", audioContent.Data);
+ writer.WriteString("data", audioContent.Data.Span);
writer.WriteString("mimeType", audioContent.MimeType);
break;
@@ -376,14 +379,73 @@ public sealed class TextContentBlock : ContentBlock
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class ImageContentBlock : ContentBlock
{
+ private ReadOnlyMemory? _decodedData;
+ private ReadOnlyMemory _data;
+
+ ///
+ /// Creates an from decoded image bytes.
+ ///
+ /// The unencoded image bytes.
+ /// The MIME type of the image.
+ /// A new instance.
+ ///
+ /// This method stores the provided bytes as and encodes them to base64 UTF-8 bytes for .
+ ///
+ ///
+ public static ImageContentBlock FromBytes(ReadOnlyMemory bytes, string mimeType)
+ {
+ ReadOnlyMemory data = EncodingUtilities.EncodeToBase64Utf8(bytes);
+
+ return new()
+ {
+ _decodedData = bytes,
+ Data = data,
+ MimeType = mimeType
+ };
+ }
+
///
public override string Type => "image";
///
- /// Gets or sets the base64-encoded image data.
+ /// Gets or sets the base64-encoded UTF-8 bytes representing the image data.
///
+ ///
+ /// Setting this value will invalidate any cached value of .
+ ///
[JsonPropertyName("data")]
- public required string Data { get; set; }
+ public required ReadOnlyMemory Data
+ {
+ get => _data;
+ set
+ {
+ _data = value;
+ _decodedData = null; // Invalidate cache
+ }
+ }
+
+ ///
+ /// Gets the decoded image data represented by .
+ ///
+ ///
+ ///
+ /// When getting, this member will decode the value in and cache the result.
+ /// Subsequent accesses return the cached value unless is modified.
+ ///
+ ///
+ [JsonIgnore]
+ public ReadOnlyMemory DecodedData
+ {
+ get
+ {
+ if (_decodedData is null)
+ {
+ _decodedData = EncodingUtilities.DecodeFromBase64Utf8(Data);
+ }
+
+ return _decodedData.Value;
+ }
+ }
///
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
@@ -402,14 +464,73 @@ public sealed class ImageContentBlock : ContentBlock
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class AudioContentBlock : ContentBlock
{
+ private ReadOnlyMemory? _decodedData;
+ private ReadOnlyMemory _data;
+
+ ///