Skip to content

Tools registered with WithTools keep a disposed service provider in memory #1898

Description

@yasmoradi

Describe the bug

A service provider that creates its tools through WithTools or WithToolsFromAssembly stays in memory for the life of the process, even after it's disposed.

CreateAIFunctionFactoryOptions hands AIFunctionFactory a ConfigureParameterBinding lambda that captures the McpServerToolCreateOptions, and with them the Services these registrations pass in. Microsoft.Extensions.AI caches the descriptor of each tool method in a static ConditionalWeakTable keyed by the serializer options, and that lambda is part of the key of every entry. Unless serializer options are passed, they're McpJsonUtilities.DefaultOptions, which never go away. So each service provider adds entries that no later lookup can match, since the lambda is new every time, and each entry keeps its provider alive. Only the cache's limit of 512 entries puts a cap on it.

An app with a single service provider won't notice. A test project that starts an app per test does. In a dump of ours, this held 67 of the 110 disposed service providers.

To Reproduce

ModelContextProtocol 2.2.0 and Microsoft.Extensions.DependencyInjection 10.0 on net10.0:

using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol;
using ModelContextProtocol.Server;

var ownOptions = args.Contains("--own-serializer-options");

List<WeakReference> providers = [];
for (var i = 0; i < 20; i++)
    providers.Add(await CreateAndDispose(ownOptions));

for (var i = 0; i < 3; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

Console.WriteLine($"own serializer options: {ownOptions}, {providers.Count(p => p.IsAlive)} of {providers.Count} disposed service providers are still alive");

[MethodImpl(MethodImplOptions.NoInlining)]
static async Task<WeakReference> CreateAndDispose(bool ownOptions)
{
    var services = new ServiceCollection();

    if (ownOptions)
        services.AddMcpServer().WithTools<EchoTools>(new JsonSerializerOptions(McpJsonUtilities.DefaultOptions));
    else
        services.AddMcpServer().WithTools<EchoTools>();

    await using var provider = services.BuildServiceProvider();

    _ = provider.GetServices<McpServerTool>().ToList();

    return new WeakReference(provider);
}

[McpServerToolType]
public sealed class EchoTools
{
    [McpServerTool, Description("Echoes the message back.")]
    public string Echo(string message) => message;
}

Output:

own serializer options: False, 20 of 20 disposed service providers are still alive
own serializer options: True, 0 of 20 disposed service providers are still alive

Expected behavior

A disposed service provider can be collected, whether or not serializer options were passed.

Additional context

The path from our dump:

McpJsonUtilities.DefaultOptions
  > ConcurrentDictionary<DescriptorKey, ReflectionAIFunctionDescriptor>
  > Func<ParameterInfo, ParameterBindingOptions>
  > AIFunctionMcpServerTool+<>c__DisplayClass4_0
  > McpServerToolCreateOptions
  > ServiceProviderEngineScope

Giving each app its own copy of the serializer options, as the second run above does, works around it for now.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions