* Overhaul tool handling
- Renames [McpTool{Type}] to [McpServerTool{Type}], in order to distinguish representations of tools on the server from tools on the client.
- Enables [McpServerTool] methods to have arguments injected from DI, as well as a specific set of types from the implementation directly, like IMcpServer.
- Renames WithTools to WithToolsFromAssembly.
- All of the WithToolsXx methods now publish each individual McpServerTool into DI; other code can do so as well. The options setup code gathers all of the tools from DI and combines them into a collection which is then stored in McpServerOptions and used to construct the server.
- The server tools specified via DI as well as manually-provided handlers, using CallToolHandler as a fallback in case the requested tool doesn't exist in the tools collection, and ListToolsHandler augments the information for whatever tools exist.
- The tools are stored in McpServerOptions in a new McpServerToolCollection type, which is a thread-safe container that exposes add/removal notification. Adding/removing tools triggers a change notification that in turn sends a notification to the client about a tools update.
- The ServerOptions are exposed from the server instance.
- Removed cursor-based APIs from McpClientExtensions.
- Changed McpClientExtensions APIs to return `Task<IList<...>` rather than `IAsyncEnumerable<...>`.
I'm sure this will need to evolve further before we're "done", but it's a significant improvement from where we are now. One area I'm not super happy with is the notifying collection; that might work ok for a stdio server, where you can grab the created collection from the server's options and mutate it, but I don't know how well that's going to work for sse servers.
* Make McpServerTool{Collection} extensible
* Fix README.md example
* Add notifications test
* Address feedback
135 lines
4.8 KiB
C#
135 lines
4.8 KiB
C#
// Licensed to the .NET Foundation under one or more agreements.
|
|
// The .NET Foundation licenses this file to you under the MIT license.
|
|
|
|
using ModelContextProtocol.Utils;
|
|
using System.Buffers;
|
|
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Microsoft.Extensions.AI;
|
|
|
|
internal static partial class TemporaryAIFunctionFactory
|
|
{
|
|
/// <summary>
|
|
/// Removes characters from a .NET member name that shouldn't be used in an AI function name.
|
|
/// </summary>
|
|
/// <param name="memberName">The .NET member name that should be sanitized.</param>
|
|
/// <returns>
|
|
/// Replaces non-alphanumeric characters in the identifier with the underscore character.
|
|
/// Primarily intended to remove characters produced by compiler-generated method name mangling.
|
|
/// </returns>
|
|
internal static string SanitizeMemberName(string memberName)
|
|
{
|
|
Throw.IfNull(memberName);
|
|
return InvalidNameCharsRegex().Replace(memberName, "_");
|
|
}
|
|
|
|
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
|
|
#if NET
|
|
[GeneratedRegex("[^0-9A-Za-z_]")]
|
|
private static partial Regex InvalidNameCharsRegex();
|
|
#else
|
|
private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex;
|
|
private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled);
|
|
#endif
|
|
|
|
/// <summary>Invokes the MethodInfo with the specified target object and arguments.</summary>
|
|
private static object? ReflectionInvoke(MethodInfo method, object? target, object?[]? arguments)
|
|
{
|
|
#if NET
|
|
return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null);
|
|
#else
|
|
try
|
|
{
|
|
return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null);
|
|
}
|
|
catch (TargetInvocationException e) when (e.InnerException is not null)
|
|
{
|
|
// If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions
|
|
// is ignored, the original exception will be wrapped in a TargetInvocationException.
|
|
// Unwrap it and throw that original exception, maintaining its stack information.
|
|
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw();
|
|
throw;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// <summary>
|
|
/// Implements a simple write-only memory stream that uses pooled buffers.
|
|
/// </summary>
|
|
private sealed class PooledMemoryStream : Stream
|
|
{
|
|
private const int DefaultBufferSize = 4096;
|
|
private byte[] _buffer;
|
|
private int _position;
|
|
|
|
public PooledMemoryStream(int initialCapacity = DefaultBufferSize)
|
|
{
|
|
_buffer = ArrayPool<byte>.Shared.Rent(initialCapacity);
|
|
_position = 0;
|
|
}
|
|
|
|
public ReadOnlySpan<byte> GetBuffer() => _buffer.AsSpan(0, _position);
|
|
public override bool CanWrite => true;
|
|
public override bool CanRead => false;
|
|
public override bool CanSeek => false;
|
|
public override long Length => _position;
|
|
public override long Position
|
|
{
|
|
get => _position;
|
|
set => throw new NotSupportedException();
|
|
}
|
|
|
|
public override void Write(byte[] buffer, int offset, int count)
|
|
{
|
|
EnsureNotDisposed();
|
|
EnsureCapacity(_position + count);
|
|
|
|
Buffer.BlockCopy(buffer, offset, _buffer, _position, count);
|
|
_position += count;
|
|
}
|
|
|
|
public override void Flush()
|
|
{
|
|
}
|
|
|
|
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (_buffer is not null)
|
|
{
|
|
ArrayPool<byte>.Shared.Return(_buffer);
|
|
_buffer = null!;
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
private void EnsureCapacity(int requiredCapacity)
|
|
{
|
|
if (requiredCapacity <= _buffer.Length)
|
|
{
|
|
return;
|
|
}
|
|
|
|
int newCapacity = Math.Max(requiredCapacity, _buffer.Length * 2);
|
|
byte[] newBuffer = ArrayPool<byte>.Shared.Rent(newCapacity);
|
|
Buffer.BlockCopy(_buffer, 0, newBuffer, 0, _position);
|
|
|
|
ArrayPool<byte>.Shared.Return(_buffer);
|
|
_buffer = newBuffer;
|
|
}
|
|
|
|
private void EnsureNotDisposed()
|
|
{
|
|
if (_buffer is null)
|
|
{
|
|
Throw();
|
|
static void Throw() => throw new ObjectDisposedException(nameof(PooledMemoryStream));
|
|
}
|
|
}
|
|
}
|
|
}
|