Reduce dependencies to 8.x versions of packages (#195)

* Reduce dependencies to 8.x versions of packages

When building for netstandard2.0 or net8.0, the ModelContextProtocol library now only depends on 8.x versions of nuget packages, rather than using the newer 9.x versions. The exception to this are packages that don't have 8.x versions.

This also introduces a net9.0 tfm. When building for net9.0, it'll use 9.x versions of packages.

The PR temporarily removes the Microsoft.Extensions.AI.nupkg reference (the Microsoft.Extensions.AI.Abstractions reference is still very much there). The latest Microsoft.Extensions.AI package on nuget has a System.Text.Json 9.x reference, but the next build to be published drops that down to 8.x as well. Once that build is published, the dependency will be added back, and temporary polyfills (in particular TemporaryAIFunctionFactory) will be deleted.

* Fix StdioClientSessionTransport shutdown

StreamClientSessionTransport's ReadMessageAsync method does await CleanupAsync in its finally. CleanupAsync awaits _readTask. _readTask _is_ ReadMessageAsync. So it's waiting for itself to complete, which will then always timeout. We may want to refactor how this works more in the future, but for now, make sure it doesn't wait for itself.

* Fix dangling requests when server unexpectedly shuts down

If the client makes a request to the server and while waiting for the response the server shuts down, the client hangs. Make sure that when the client's message loops ends due to the server's output it's reading from ends, any pending requests are notified.

* Add net9.0 testing

* Avoid docker port conflicts from test suites running concurrently
This commit is contained in:
Stephen Toub 2025-04-03 14:01:46 -04:00 • committed by GitHub
commit c452dc8e26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 247 additions and 91 deletions

View file

@ -1,36 +1,59 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<SystemVersion>9.0.3</SystemVersion>
<System10Version>10.0.0-preview.2.25163.2</System10Version> <System10Version>10.0.0-preview.2.25163.2</System10Version>
<MicrosoftExtensionsVersion>9.0.3</MicrosoftExtensionsVersion>
<MicrosoftExtensionsAIVersion>9.3.0-preview.1.25161.3</MicrosoftExtensionsAIVersion> <MicrosoftExtensionsAIVersion>9.3.0-preview.1.25161.3</MicrosoftExtensionsAIVersion>
</PropertyGroup> </PropertyGroup>
<!-- Product dependencies netstandard -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageVersion Include="Microsoft.Bcl.Memory" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
<PackageVersion Include="System.Threading.Channels" Version="8.0.0" />
</ItemGroup>
<!-- Product dependencies LTS -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
</ItemGroup>
<!-- Product dependencies .NET 9 -->
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageVersion Include="System.IO.Pipelines" Version="9.0.0" />
</ItemGroup>
<!-- Product dependencies shared -->
<ItemGroup> <ItemGroup>
<!-- Product dependencies -->
<PackageVersion Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.Bcl.Memory" Version="$(SystemVersion)" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="$(MicrosoftExtensionsAIVersion)" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="$(MicrosoftExtensionsAIVersion)" /> <PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="$(MicrosoftExtensionsAIVersion)" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(MicrosoftExtensionsVersion)" /> <PackageVersion Include="Microsoft.Extensions.AI" Version="$(MicrosoftExtensionsAIVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsVersion)" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="$(System10Version)" /> <PackageVersion Include="System.Net.ServerSentEvents" Version="$(System10Version)" />
<PackageVersion Include="System.Text.Json" Version="$(SystemVersion)" /> </ItemGroup>
<PackageVersion Include="System.Threading.Channels" Version="$(SystemVersion)" />
<ItemGroup>
<!-- Build Infra & Packaging --> <!-- Build Infra & Packaging -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" /> <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<!-- Testing dependencies --> <!-- Testing dependencies -->
<PackageVersion Include="Anthropic.SDK" Version="5.0.0" /> <PackageVersion Include="Anthropic.SDK" Version="5.0.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" /> <PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="$(MicrosoftExtensionsAIVersion)" /> <PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="$(MicrosoftExtensionsAIVersion)" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="$(MicrosoftExtensionsVersion)" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="$(MicrosoftExtensionsVersion)" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="$(MicrosoftExtensionsVersion)" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="OpenTelemetry" Version="1.11.2" /> <PackageVersion Include="OpenTelemetry" Version="1.11.2" />

View file

@ -15,6 +15,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Anthropic.SDK" /> <PackageReference Include="Anthropic.SDK" />
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,17 @@
using System.Runtime.CompilerServices;
namespace System.Threading.Channels;
internal static class ChannelExtensions
{
public static async IAsyncEnumerable<T> ReadAllAsync<T>(this ChannelReader<T> reader, [EnumeratorCancellation] CancellationToken cancellationToken)
{
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (reader.TryRead(out var item))
{
yield return item;
}
}
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>

View file

@ -24,7 +24,7 @@ public static class McpClientExtensions
return client.SendRequestAsync( return client.SendRequestAsync(
RequestMethods.Ping, RequestMethods.Ping,
parameters: null, parameters: null,
McpJsonUtilities.JsonContext.Default.Object, McpJsonUtilities.JsonContext.Default.Object!,
McpJsonUtilities.JsonContext.Default.Object, McpJsonUtilities.JsonContext.Default.Object,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
@ -52,7 +52,7 @@ public static class McpClientExtensions
{ {
var toolResults = await client.SendRequestAsync( var toolResults = await client.SendRequestAsync(
RequestMethods.ToolsList, RequestMethods.ToolsList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListToolsResult, McpJsonUtilities.JsonContext.Default.ListToolsResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -96,7 +96,7 @@ public static class McpClientExtensions
{ {
var toolResults = await client.SendRequestAsync( var toolResults = await client.SendRequestAsync(
RequestMethods.ToolsList, RequestMethods.ToolsList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListToolsResult, McpJsonUtilities.JsonContext.Default.ListToolsResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -128,7 +128,7 @@ public static class McpClientExtensions
{ {
var promptResults = await client.SendRequestAsync( var promptResults = await client.SendRequestAsync(
RequestMethods.PromptsList, RequestMethods.PromptsList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListPromptsResult, McpJsonUtilities.JsonContext.Default.ListPromptsResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -166,7 +166,7 @@ public static class McpClientExtensions
{ {
var promptResults = await client.SendRequestAsync( var promptResults = await client.SendRequestAsync(
RequestMethods.PromptsList, RequestMethods.PromptsList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListPromptsResult, McpJsonUtilities.JsonContext.Default.ListPromptsResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -230,7 +230,7 @@ public static class McpClientExtensions
{ {
var templateResults = await client.SendRequestAsync( var templateResults = await client.SendRequestAsync(
RequestMethods.ResourcesTemplatesList, RequestMethods.ResourcesTemplatesList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -271,7 +271,7 @@ public static class McpClientExtensions
{ {
var templateResults = await client.SendRequestAsync( var templateResults = await client.SendRequestAsync(
RequestMethods.ResourcesTemplatesList, RequestMethods.ResourcesTemplatesList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -304,7 +304,7 @@ public static class McpClientExtensions
{ {
var resourceResults = await client.SendRequestAsync( var resourceResults = await client.SendRequestAsync(
RequestMethods.ResourcesList, RequestMethods.ResourcesList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourcesResult, McpJsonUtilities.JsonContext.Default.ListResourcesResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -345,7 +345,7 @@ public static class McpClientExtensions
{ {
var resourceResults = await client.SendRequestAsync( var resourceResults = await client.SendRequestAsync(
RequestMethods.ResourcesList, RequestMethods.ResourcesList,
CreateCursorDictionary(cursor), CreateCursorDictionary(cursor)!,
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourcesResult, McpJsonUtilities.JsonContext.Default.ListResourcesResult,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
@ -374,7 +374,7 @@ public static class McpClientExtensions
return client.SendRequestAsync( return client.SendRequestAsync(
RequestMethods.ResourcesRead, RequestMethods.ResourcesRead,
new Dictionary<string, object?> { ["uri"] = uri }, new Dictionary<string, object> { ["uri"] = uri },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ReadResourceResult, McpJsonUtilities.JsonContext.Default.ReadResourceResult,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
@ -401,7 +401,7 @@ public static class McpClientExtensions
return client.SendRequestAsync( return client.SendRequestAsync(
RequestMethods.CompletionComplete, RequestMethods.CompletionComplete,
new Dictionary<string, object?> new Dictionary<string, object>
{ {
["ref"] = reference, ["ref"] = reference,
["argument"] = new Argument { Name = argumentName, Value = argumentValue } ["argument"] = new Argument { Name = argumentName, Value = argumentValue }
@ -424,7 +424,7 @@ public static class McpClientExtensions
return client.SendRequestAsync( return client.SendRequestAsync(
RequestMethods.ResourcesSubscribe, RequestMethods.ResourcesSubscribe,
new Dictionary<string, object?> { ["uri"] = uri }, new Dictionary<string, object> { ["uri"] = uri },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.EmptyResult, McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
@ -443,7 +443,7 @@ public static class McpClientExtensions
return client.SendRequestAsync( return client.SendRequestAsync(
RequestMethods.ResourcesUnsubscribe, RequestMethods.ResourcesUnsubscribe,
new Dictionary<string, object?> { ["uri"] = uri }, new Dictionary<string, object> { ["uri"] = uri },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.EmptyResult, McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
@ -629,7 +629,7 @@ public static class McpClientExtensions
return client.SendRequestAsync( return client.SendRequestAsync(
RequestMethods.LoggingSetLevel, RequestMethods.LoggingSetLevel,
new Dictionary<string, object?> { ["level"] = level }, new Dictionary<string, object> { ["level"] = level },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject, McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.EmptyResult, McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);

View file

@ -7,10 +7,20 @@ internal static class Diagnostics
{ {
internal static ActivitySource ActivitySource { get; } = new("Experimental.ModelContextProtocol"); internal static ActivitySource ActivitySource { get; } = new("Experimental.ModelContextProtocol");
internal static Meter Meter { get; } = new("Experimental.ModelContextProtocol");
internal static Histogram<double> CreateDurationHistogram(string name, string description, bool longBuckets) =>
Diagnostics.Meter.CreateHistogram<double>(name, "s", description
#if NET9_0_OR_GREATER
, advice: longBuckets ? LongSecondsBucketBoundaries : ShortSecondsBucketBoundaries
#endif
);
#if NET9_0_OR_GREATER
/// <summary> /// <summary>
/// Follows boundaries from http.server.request.duration/http.client.request.duration /// Follows boundaries from http.server.request.duration/http.client.request.duration
/// </summary> /// </summary>
internal static InstrumentAdvice<double> ShortSecondsBucketBoundaries { get; } = new() private static InstrumentAdvice<double> ShortSecondsBucketBoundaries { get; } = new()
{ {
HistogramBucketBoundaries = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10], HistogramBucketBoundaries = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
}; };
@ -19,11 +29,9 @@ internal static class Diagnostics
/// Not based on a standard. Larger bucket sizes for longer lasting operations, e.g. HTTP connection duration. /// Not based on a standard. Larger bucket sizes for longer lasting operations, e.g. HTTP connection duration.
/// See https://github.com/open-telemetry/semantic-conventions/issues/336 /// See https://github.com/open-telemetry/semantic-conventions/issues/336
/// </summary> /// </summary>
internal static InstrumentAdvice<double> LongSecondsBucketBoundaries { get; } = new() private static InstrumentAdvice<double> LongSecondsBucketBoundaries { get; } = new()
{ {
HistogramBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300], HistogramBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
}; };
#endif
internal static Meter Meter { get; } = new("Experimental.ModelContextProtocol");
} }

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks> <TargetFrameworks>net9.0;net8.0;netstandard2.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<PackageId>ModelContextProtocol</PackageId> <PackageId>ModelContextProtocol</PackageId>
@ -13,14 +13,7 @@
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <!-- Dependencies only needed by netstandard2.0 -->
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.Net.ServerSentEvents" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'"> <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<Compile Include="..\Common\Polyfills\**\*.cs" /> <Compile Include="..\Common\Polyfills\**\*.cs" />
<PackageReference Include="Microsoft.Bcl.Memory" /> <PackageReference Include="Microsoft.Bcl.Memory" />
@ -28,6 +21,24 @@
<PackageReference Include="System.Threading.Channels" /> <PackageReference Include="System.Threading.Channels" />
</ItemGroup> </ItemGroup>
<!-- Dependencies only needed by netstandard2.0 or net8.0 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0' or '$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.IO.Pipelines" />
</ItemGroup>
<!-- Dependencies needed by all -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.Net.ServerSentEvents" />
<!-- Temporarily removed until new version can be picked up that
has reduced dependencies:
<PackageReference Include="Microsoft.Extensions.AI" />
-->
</ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\README.md" pack="true" PackagePath="\" /> <None Include="..\..\README.md" pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>

View file

@ -53,7 +53,7 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
return; return;
} }
JsonSerializer.Serialize(GetUtf8JsonWriter(writer), item.Data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage); JsonSerializer.Serialize(GetUtf8JsonWriter(writer), item.Data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage!);
} }
/// <inheritdoc/> /// <inheritdoc/>

View file

@ -29,9 +29,16 @@ internal class StreamClientSessionTransport : TransportBase
_serverInput = serverInput; _serverInput = serverInput;
EndpointName = endpointName; EndpointName = endpointName;
// Start reading messages in the background // 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.
Logger.TransportReadingMessages(endpointName); Logger.TransportReadingMessages(endpointName);
_readTask = Task.Run(() => ReadMessagesAsync(_shutdownCts.Token), CancellationToken.None); var readTask = new Task<Task>(
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
this,
TaskCreationOptions.DenyChildAttach);
_readTask = readTask.Unwrap();
readTask.Start();
SetConnected(true); SetConnected(true);
} }
@ -117,6 +124,7 @@ internal class StreamClientSessionTransport : TransportBase
} }
finally finally
{ {
_readTask = null;
await CleanupAsync(cancellationToken).ConfigureAwait(false); await CleanupAsync(cancellationToken).ConfigureAwait(false);
} }
} }

View file

@ -6,7 +6,7 @@ namespace ModelContextProtocol.Protocol.Types;
/// A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. /// A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">See the schema for details</see> /// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">See the schema for details</see>
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ContextInclusion>))] [JsonConverter(typeof(CustomizableJsonStringEnumConverter<ContextInclusion>))]
public enum ContextInclusion public enum ContextInclusion
{ {
/// <summary> /// <summary>

View file

@ -7,7 +7,7 @@ namespace ModelContextProtocol.Protocol.Types;
/// These map to syslog message severities, as specified in RFC-5424: /// These map to syslog message severities, as specified in RFC-5424:
/// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 /// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<LoggingLevel>))] [JsonConverter(typeof(CustomizableJsonStringEnumConverter<LoggingLevel>))]
public enum LoggingLevel public enum LoggingLevel
{ {
/// <summary>Detailed debug information, typically only valuable to developers.</summary> /// <summary>Detailed debug information, typically only valuable to developers.</summary>

View file

@ -6,7 +6,7 @@ namespace ModelContextProtocol.Protocol.Types;
/// Represents the type of role in the conversation. /// Represents the type of role in the conversation.
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">See the schema for details</see> /// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">See the schema for details</see>
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<Role>))] [JsonConverter(typeof(CustomizableJsonStringEnumConverter<Role>))]
public enum Role public enum Role
{ {
/// <summary> /// <summary>

View file

@ -133,9 +133,9 @@ internal static partial class TemporaryAIFunctionFactory
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s /// Return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
/// <see cref="AIFunctionFactoryOptions.SerializerOptions"/>. Arguments that are not already of the expected type are /// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. Arguments that are not already of the expected type are
/// marshaled to the expected type via JSON and using <paramref name="options"/>'s /// marshaled to the expected type via JSON and using <paramref name="options"/>'s
/// <see cref="AIFunctionFactoryOptions.SerializerOptions"/>. If the argument is a <see cref="JsonElement"/>, /// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. If the argument is a <see cref="JsonElement"/>,
/// <see cref="JsonDocument"/>, or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown, /// <see cref="JsonDocument"/>, or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown,
/// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. /// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type.
/// </para> /// </para>

View file

@ -72,7 +72,7 @@ internal sealed class TemporaryAIFunctionFactoryOptions
/// <summary>Gets or sets a delegate used to determine the <see cref="object"/> returned by <see cref="AIFunction.InvokeAsync"/>.</summary> /// <summary>Gets or sets a delegate used to determine the <see cref="object"/> returned by <see cref="AIFunction.InvokeAsync"/>.</summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// By default, the return value of invoking the method wrapped into an <see cref="AIFunction"/> by <see cref="AIFunctionFactory"/> /// By default, the return value of invoking the method wrapped into an <see cref="AIFunction"/> by <see cref="TemporaryAIFunctionFactory"/>
/// is then JSON serialized, with the resulting <see cref="JsonElement"/> returned from the <see cref="AIFunction.InvokeAsync"/> method. /// is then JSON serialized, with the resulting <see cref="JsonElement"/> returned from the <see cref="AIFunction.InvokeAsync"/> method.
/// This default behavior is ideal for the common case where the result will be passed back to an AI service. However, if the caller /// This default behavior is ideal for the common case where the result will be passed back to an AI service. However, if the caller
/// requires more control over the result's marshaling, the <see cref="MarshalResult"/> property may be set to a delegate that is /// requires more control over the result's marshaling, the <see cref="MarshalResult"/> property may be set to a delegate that is
@ -82,7 +82,7 @@ internal sealed class TemporaryAIFunctionFactoryOptions
/// <para> /// <para>
/// When set, the delegate is invoked even for <see langword="void"/>-returning methods, in which case it is invoked with /// When set, the delegate is invoked even for <see langword="void"/>-returning methods, in which case it is invoked with
/// a <see langword="null"/> argument. By default, <see langword="null"/> is returned from the <see cref="AIFunction.InvokeAsync"/> /// a <see langword="null"/> argument. By default, <see langword="null"/> is returned from the <see cref="AIFunction.InvokeAsync"/>
/// method for <see cref="AIFunction"/> instances produced by <see cref="AIFunctionFactory"/> to wrap /// method for <see cref="AIFunction"/> instances produced by <see cref="TemporaryAIFunctionFactory"/> to wrap
/// <see langword="void"/>-returning methods). /// <see langword="void"/>-returning methods).
/// </para> /// </para>
/// <para> /// <para>

View file

@ -12,6 +12,7 @@ using System.Diagnostics;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Threading.Channels;
namespace ModelContextProtocol.Shared; namespace ModelContextProtocol.Shared;
@ -20,15 +21,14 @@ namespace ModelContextProtocol.Shared;
/// </summary> /// </summary>
internal sealed class McpSession : IDisposable internal sealed class McpSession : IDisposable
{ {
private static readonly Histogram<double> s_clientSessionDuration = Diagnostics.Meter.CreateHistogram<double>( private static readonly Histogram<double> s_clientSessionDuration = Diagnostics.CreateDurationHistogram(
"mcp.client.session.duration", "s", "Measures the duration of a client session.", advice: Diagnostics.LongSecondsBucketBoundaries); "mcp.client.session.duration", "Measures the duration of a client session.", longBuckets: true);
private static readonly Histogram<double> s_serverSessionDuration = Diagnostics.Meter.CreateHistogram<double>( private static readonly Histogram<double> s_serverSessionDuration = Diagnostics.CreateDurationHistogram(
"mcp.server.session.duration", "s", "Measures the duration of a server session.", advice: Diagnostics.LongSecondsBucketBoundaries); "mcp.server.session.duration", "Measures the duration of a server session.", longBuckets: true);
private static readonly Histogram<double> s_clientRequestDuration = Diagnostics.CreateDurationHistogram(
private static readonly Histogram<double> s_serverRequestDuration = Diagnostics.Meter.CreateHistogram<double>( "rpc.client.duration", "Measures the duration of outbound RPC.", longBuckets: false);
"rpc.server.duration", "s", "Measures the duration of inbound RPC.", advice: Diagnostics.ShortSecondsBucketBoundaries); private static readonly Histogram<double> s_serverRequestDuration = Diagnostics.CreateDurationHistogram(
private static readonly Histogram<double> s_clientRequestDuration = Diagnostics.Meter.CreateHistogram<double>( "rpc.server.duration", "Measures the duration of inbound RPC.", longBuckets: false);
"rpc.client.duration", "s", "Measures the duration of outbound RPC.", advice: Diagnostics.ShortSecondsBucketBoundaries);
private readonly bool _isServer; private readonly bool _isServer;
private readonly string _transportKind; private readonly string _transportKind;
@ -174,6 +174,14 @@ internal sealed class McpSession : IDisposable
// Normal shutdown // Normal shutdown
_logger.EndpointMessageProcessingCancelled(EndpointName); _logger.EndpointMessageProcessingCancelled(EndpointName);
} }
finally
{
// Fail any pending requests, as they'll never be satisfied.
foreach (var entry in _pendingRequests)
{
entry.Value.TrySetException(new InvalidOperationException("The server shut down unexpectedly."));
}
}
} }
private async Task HandleMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken) private async Task HandleMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken)

View file

@ -0,0 +1,64 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
// NOTE:
// This is a temporary workaround for lack of System.Text.Json's JsonStringEnumConverter<T>
// 9.x support for JsonStringEnumMemberNameAttribute. Once all builds use the System.Text.Json 9.x
// version, this whole file can be removed.
namespace System.Text.Json.Serialization;
internal sealed class CustomizableJsonStringEnumConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum> :
JsonStringEnumConverter<TEnum> where TEnum : struct, Enum
{
#if !NET9_0_OR_GREATER
public CustomizableJsonStringEnumConverter() :
base(namingPolicy: ResolveNamingPolicy())
{
}
private static JsonNamingPolicy? ResolveNamingPolicy()
{
var map = typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static)
.Select(f => (f.Name, AttributeName: f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name))
.Where(pair => pair.AttributeName != null)
.ToDictionary(pair => pair.Name, pair => pair.AttributeName);
return map.Count > 0 ? new EnumMemberNamingPolicy(map!) : null;
}
private sealed class EnumMemberNamingPolicy(Dictionary<string, string> map) : JsonNamingPolicy
{
public override string ConvertName(string name) =>
map.TryGetValue(name, out string? newName) ?
newName :
name;
}
#endif
}
#if !NET9_0_OR_GREATER
/// <summary>
/// Determines the string value that should be used when serializing an enum member.
/// </summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
internal sealed class JsonStringEnumMemberNameAttribute : Attribute
{
/// <summary>
/// Creates new attribute instance with a specified enum member name.
/// </summary>
/// <param name="name">The name to apply to the current enum member.</param>
public JsonStringEnumMemberNameAttribute(string name)
{
Name = name;
}
/// <summary>
/// Gets the name of the enum member.
/// </summary>
public string Name { get; }
}
#endif

View file

@ -78,7 +78,6 @@ public static partial class McpJsonUtilities
// Keep in sync with CreateDefaultOptions above. // Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, [JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)] NumberHandling = JsonNumberHandling.AllowReadingFromString)]

View file

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyName>TestServer</AssemblyName> <AssemblyName>TestServer</AssemblyName>
@ -10,8 +10,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Logging" /> <PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Serilog.Extensions.Logging" /> <PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.File" />
</ItemGroup> </ItemGroup>

View file

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyName>TestSseServer</AssemblyName> <AssemblyName>TestSseServer</AssemblyName>
@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" /> <PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Serilog.Extensions.Logging" /> <PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.File" />
</ItemGroup> </ItemGroup>

View file

@ -377,10 +377,12 @@ public class Program
{ {
Console.WriteLine("Starting server..."); Console.WriteLine("Starting server...");
int port = args.Length > 0 && uint.TryParse(args[0], out var parsedPort) ? (int)parsedPort : 3001;
var builder = WebApplication.CreateSlimBuilder(args); var builder = WebApplication.CreateSlimBuilder(args);
builder.WebHost.ConfigureKestrel(options => builder.WebHost.ConfigureKestrel(options =>
{ {
options.ListenLocalhost(3001); options.ListenLocalhost(port);
}); });
ConfigureSerilog(builder.Logging); ConfigureSerilog(builder.Logging);

Some files were not shown because too many files have changed in this diff Show more