diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index ded6131a..27514aeb 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -59,3 +59,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License notice for URI Template Tests +------------------------------------- + +Copyright 2011- The Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/src/Common/Polyfills/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs b/src/Common/Polyfills/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs new file mode 100644 index 00000000..515ef63c --- /dev/null +++ b/src/Common/Polyfills/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs @@ -0,0 +1,617 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Copied from: +// https://github.com/dotnet/runtime/blob/dd75c45c123055baacd7aa4418f425f412797a29/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs +// and then modified to build on netstandard2.0. + +using System.Buffers; +using System.Diagnostics; +using System.Globalization; + +namespace System.Runtime.CompilerServices +{ + /// Provides a handler used by the language compiler to process interpolated strings into instances. + public ref struct DefaultInterpolatedStringHandler + { + // Implementation note: + // As this type lives in CompilerServices and is only intended to be targeted by the compiler, + // public APIs eschew argument validation logic in a variety of places, e.g. allowing a null input + // when one isn't expected to produce a NullReferenceException rather than an ArgumentNullException. + + /// Expected average length of formatted data used for an individual interpolation expression result. + /// + /// This is inherited from string.Format, and could be changed based on further data. + /// string.Format actually uses `format.Length + args.Length * 8`, but format.Length + /// includes the format items themselves, e.g. "{0}", and since it's rare to have double-digit + /// numbers of items, we bump the 8 up to 11 to account for the three extra characters in "{d}", + /// since the compiler-provided base length won't include the equivalent character count. + /// + private const int GuessedLengthPerHole = 11; + /// Minimum size array to rent from the pool. + /// Same as stack-allocation size used today by string.Format. + private const int MinimumArrayPoolLength = 256; + + /// Optional provider to pass to IFormattable.ToString or ISpanFormattable.TryFormat calls. + private readonly IFormatProvider? _provider; + /// Array rented from the array pool and used to back . + private char[]? _arrayToReturnToPool; + /// The span to write into. + private Span _chars; + /// Position at which to write the next character. + private int _pos; + /// Whether provides an ICustomFormatter. + /// + /// Custom formatters are very rare. We want to support them, but it's ok if we make them more expensive + /// in order to make them as pay-for-play as possible. So, we avoid adding another reference type field + /// to reduce the size of the handler and to reduce required zero'ing, by only storing whether the provider + /// provides a formatter, rather than actually storing the formatter. This in turn means, if there is a + /// formatter, we pay for the extra interface call on each AppendFormatted that needs it. + /// + private readonly bool _hasCustomFormatter; + + /// Creates a handler used to translate an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public DefaultInterpolatedStringHandler(int literalLength, int formattedCount) + { + _provider = null; + _chars = _arrayToReturnToPool = ArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _pos = 0; + _hasCustomFormatter = false; + } + + /// Creates a handler used to translate an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// An object that supplies culture-specific formatting information. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider) + { + _provider = provider; + _chars = _arrayToReturnToPool = ArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _pos = 0; + _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); + } + + /// Creates a handler used to translate an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// An object that supplies culture-specific formatting information. + /// A buffer temporarily transferred to the handler for use as part of its formatting. Contents may be overwritten. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider, Span initialBuffer) + { + _provider = provider; + _chars = initialBuffer; + _arrayToReturnToPool = null; + _pos = 0; + _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); + } + + /// Derives a default length with which to seed the handler. + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // becomes a constant when inputs are constant + internal static int GetDefaultLength(int literalLength, int formattedCount) => + Math.Max(MinimumArrayPoolLength, literalLength + (formattedCount * GuessedLengthPerHole)); + + /// Gets the built . + /// The built string. + public override string ToString() => Text.ToString(); + + /// Gets the built and clears the handler. + /// The built string. + /// + /// This releases any resources used by the handler. The method should be invoked only + /// once and as the last thing performed on the handler. Subsequent use is erroneous, ill-defined, + /// and may destabilize the process, as may using any other copies of the handler after + /// is called on any one of them. + /// + public string ToStringAndClear() + { + string result = Text.ToString(); + Clear(); + return result; + } + + /// Clears the handler. + /// + /// This releases any resources used by the handler. The method should be invoked only + /// once and as the last thing performed on the handler. Subsequent use is erroneous, ill-defined, + /// and may destabilize the process, as may using any other copies of the handler after + /// is called on any one of them. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Clear() + { + char[]? toReturn = _arrayToReturnToPool; + + // Defensive clear + _arrayToReturnToPool = null; + _chars = default; + _pos = 0; + + if (toReturn is not null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + /// Gets a span of the characters appended to the handler. + public ReadOnlySpan Text => _chars.Slice(0, _pos); + + /// Writes the specified string to the handler. + /// The string to write. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(string value) + { + if (value.AsSpan().TryCopyTo(_chars.Slice(_pos))) + { + _pos += value.Length; + } + else + { + GrowThenCopyString(value); + } + } + + #region AppendFormatted + // Design note: + // The compiler requires a AppendFormatted overload for anything that might be within an interpolation expression; + // if it can't find an appropriate overload, for handlers in general it'll simply fail to compile. + // (For target-typing to string where it uses DefaultInterpolatedStringHandler implicitly, it'll instead fall back to + // its other mechanisms, e.g. using string.Format. This fallback has the benefit that if we miss a case, + // interpolated strings will still work, but it has the downside that a developer generally won't know + // if the fallback is happening and they're paying more.) + // + // At a minimum, then, we would need an overload that accepts: + // (object value, int alignment = 0, string? format = null) + // Such an overload would provide the same expressiveness as string.Format. However, this has several + // shortcomings: + // - Every value type in an interpolation expression would be boxed. + // - ReadOnlySpan could not be used in interpolation expressions. + // - Every AppendFormatted call would have three arguments at the call site, bloating the IL further. + // - Every invocation would be more expensive, due to lack of specialization, every call needing to account + // for alignment and format, etc. + // + // To address that, we could just have overloads for T and ReadOnlySpan: + // (T) + // (T, int alignment) + // (T, string? format) + // (T, int alignment, string? format) + // (ReadOnlySpan) + // (ReadOnlySpan, int alignment) + // (ReadOnlySpan, string? format) + // (ReadOnlySpan, int alignment, string? format) + // but this also has shortcomings: + // - Some expressions that would have worked with an object overload will now force a fallback to string.Format + // (or fail to compile if the handler is used in places where the fallback isn't provided), because the compiler + // can't always target type to T, e.g. `b switch { true => 1, false => null }` where `b` is a bool can successfully + // be passed as an argument of type `object` but not of type `T`. + // - Reference types get no benefit from going through the generic code paths, and actually incur some overheads + // from doing so. + // - Nullable value types also pay a heavy price, in particular around interface checks that would generally evaporate + // at compile time for value types but don't (currently) if the Nullable goes through the same code paths + // (see https://github.com/dotnet/runtime/issues/50915). + // + // We could try to take a more elaborate approach for DefaultInterpolatedStringHandler, since it is the most common handler + // and we want to minimize overheads both at runtime and in IL size, e.g. have a complete set of overloads for each of: + // (T, ...) where T : struct + // (T?, ...) where T : struct + // (object, ...) + // (ReadOnlySpan, ...) + // (string, ...) + // but this also has shortcomings, most importantly: + // - If you have an unconstrained T that happens to be a value type, it'll now end up getting boxed to use the object overload. + // This also necessitates the T? overload, since nullable value types don't meet a T : struct constraint, so without those + // they'd all map to the object overloads as well. + // - Any reference type with an implicit cast to ROS will fail to compile due to ambiguities between the overloads. string + // is one such type, hence needing dedicated overloads for it that can be bound to more tightly. + // + // A middle ground we've settled on, which is likely to be the right approach for most other handlers as well, would be the set: + // (T, ...) with no constraint + // (ReadOnlySpan) and (ReadOnlySpan, int) + // (object, int alignment = 0, string? format = null) + // (string) and (string, int) + // This would address most of the concerns, at the expense of: + // - Most reference types going through the generic code paths and so being a bit more expensive. + // - Nullable types being more expensive until https://github.com/dotnet/runtime/issues/50915 is addressed. + // We could choose to add a T? where T : struct set of overloads if necessary. + // Strings don't require their own overloads here, but as they're expected to be very common and as we can + // optimize them in several ways (can copy the contents directly, don't need to do any interface checks, don't + // need to pay the shared generic overheads, etc.) we can add overloads specifically to optimize for them. + // + // Hole values are formatted according to the following policy: + // 1. If an IFormatProvider was supplied and it provides an ICustomFormatter, use ICustomFormatter.Format (even if the value is null). + // 2. If the type implements ISpanFormattable, use ISpanFormattable.TryFormat. + // 3. If the type implements IFormattable, use IFormattable.ToString. + // 4. Otherwise, use object.ToString. + // This matches the behavior of string.Format, StringBuilder.AppendFormat, etc. The only overloads for which this doesn't + // apply is ReadOnlySpan, which isn't supported by either string.Format nor StringBuilder.AppendFormat, but more + // importantly which can't be boxed to be passed to ICustomFormatter.Format. + + #region AppendFormatted T + /// Writes the specified value to the handler. + /// The value to write. + /// The type of the value to write. + public void AppendFormatted(T value) + { + // This method could delegate to AppendFormatted with a null format, but explicitly passing + // default as the format to TryFormat helps to improve code quality in some cases when TryFormat is inlined, + // e.g. for Int32 it enables the JIT to eliminate code in the inlined method based on a length check on the format. + + // If there's a custom formatter, always use it. + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format: null); + return; + } + + string? s; + if (value is IFormattable) + { + s = ((IFormattable)value).ToString(format: null, _provider); // constrained call avoiding boxing for value types + } + else + { + s = value?.ToString(); + } + + if (s is not null) + { + AppendLiteral(s); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// The type of the value to write. + public void AppendFormatted(T value, string? format) + { + // If there's a custom formatter, always use it. + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format); + return; + } + + // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter + // requires the former. For value types, it won't matter as the type checks devolve into + // JIT-time constants. For reference types, they're more likely to implement IFormattable + // than they are to implement ISpanFormattable: if they don't implement either, we save an + // interface check over first checking for ISpanFormattable and then for IFormattable, and + // if it only implements IFormattable, we come out even: only if it implements both do we + // end up paying for an extra interface check. + string? s; + if (value is IFormattable) + { + s = ((IFormattable)value).ToString(format, _provider); // constrained call avoiding boxing for value types + } + else + { + s = value?.ToString(); + } + + if (s is not null) + { + AppendLiteral(s); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value. + /// The type of the value to write. + public void AppendFormatted(T value, int alignment) + { + int startingPos = _pos; + AppendFormatted(value); + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value. + /// The type of the value to write. + public void AppendFormatted(T value, int alignment, string? format) + { + int startingPos = _pos; + AppendFormatted(value, format); + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + #endregion + + #region AppendFormatted ReadOnlySpan + /// Writes the specified character span to the handler. + /// The span to write. + public void AppendFormatted(scoped ReadOnlySpan value) + { + // Fast path for when the value fits in the current buffer + if (value.TryCopyTo(_chars.Slice(_pos))) + { + _pos += value.Length; + } + else + { + GrowThenCopySpan(value); + } + } + + /// Writes the specified string of chars to the handler. + /// The span to write. + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value. + /// The format string. + public void AppendFormatted(scoped ReadOnlySpan value, int alignment = 0, string? format = null) + { + bool leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + int paddingRequired = alignment - value.Length; + if (paddingRequired <= 0) + { + // The value is as large or larger than the required amount of padding, + // so just write the value. + AppendFormatted(value); + return; + } + + // Write the value along with the appropriate padding. + EnsureCapacityForAdditionalChars(value.Length + paddingRequired); + if (leftAlign) + { + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + _chars.Slice(_pos, paddingRequired).Fill(' '); + _pos += paddingRequired; + } + else + { + _chars.Slice(_pos, paddingRequired).Fill(' '); + _pos += paddingRequired; + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + } + #endregion + + #region AppendFormatted string + /// Writes the specified value to the handler. + /// The value to write. + public void AppendFormatted(string? value) + { + // Fast-path for no custom formatter and a non-null string that fits in the current destination buffer. + if (!_hasCustomFormatter && + value is not null && + value.AsSpan().TryCopyTo(_chars.Slice(_pos))) + { + _pos += value.Length; + } + else + { + AppendFormattedSlow(value); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Slow path to handle a custom formatter, potentially null value, + /// or a string that doesn't fit in the current buffer. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendFormattedSlow(string? value) + { + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format: null); + } + else if (value is not null) + { + EnsureCapacityForAdditionalChars(value.Length); + value.AsSpan().CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value. + /// The format string. + public void AppendFormatted(string? value, int alignment = 0, string? format = null) => + // Format is meaningless for strings and doesn't make sense for someone to specify. We have the overload + // simply to disambiguate between ROS and object, just in case someone does specify a format, as + // string is implicitly convertible to both. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); + #endregion + + #region AppendFormatted object + /// Writes the specified value to the handler. + /// The value to write. + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value. + /// The format string. + public void AppendFormatted(object? value, int alignment = 0, string? format = null) => + // This overload is expected to be used rarely, only if either a) something strongly typed as object is + // formatted with both an alignment and a format, or b) the compiler is unable to target type to T. It + // exists purely to help make cases from (b) compile. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); + #endregion + #endregion + + /// Gets whether the provider provides a custom formatter. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // only used in a few hot path call sites + internal static bool HasCustomFormatter(IFormatProvider provider) + { + Debug.Assert(provider is not null); + Debug.Assert(provider is not CultureInfo || provider.GetFormat(typeof(ICustomFormatter)) is null, "Expected CultureInfo to not provide a custom formatter"); + return + provider!.GetType() != typeof(CultureInfo) && // optimization to avoid GetFormat in the majority case + provider.GetFormat(typeof(ICustomFormatter)) != null; + } + + /// Formats the value using the custom formatter from the provider. + /// The value to write. + /// The format string. + /// The type of the value to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendCustomFormatter(T value, string? format) + { + // This case is very rare, but we need to handle it prior to the other checks in case + // a provider was used that supplied an ICustomFormatter which wanted to intercept the particular value. + // We do the cast here rather than in the ctor, even though this could be executed multiple times per + // formatting, to make the cast pay for play. + Debug.Assert(_hasCustomFormatter); + Debug.Assert(_provider != null); + + ICustomFormatter? formatter = (ICustomFormatter?)_provider!.GetFormat(typeof(ICustomFormatter)); + Debug.Assert(formatter != null, "An incorrectly written provider said it implemented ICustomFormatter, and then didn't"); + + if (formatter is not null && formatter.Format(format, value, _provider) is string customFormatted) + { + AppendLiteral(customFormatted); + } + } + + /// Handles adding any padding required for aligning a formatted value in an interpolation expression. + /// The position at which the written value started. + /// Non-zero minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value. + private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment) + { + Debug.Assert(startingPos >= 0 && startingPos <= _pos); + Debug.Assert(alignment != 0); + + int charsWritten = _pos - startingPos; + + bool leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + int paddingNeeded = alignment - charsWritten; + if (paddingNeeded > 0) + { + EnsureCapacityForAdditionalChars(paddingNeeded); + + if (leftAlign) + { + _chars.Slice(_pos, paddingNeeded).Fill(' '); + } + else + { + _chars.Slice(startingPos, charsWritten).CopyTo(_chars.Slice(startingPos + paddingNeeded)); + _chars.Slice(startingPos, paddingNeeded).Fill(' '); + } + + _pos += paddingNeeded; + } + } + + /// Ensures has the capacity to store beyond . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureCapacityForAdditionalChars(int additionalChars) + { + if (_chars.Length - _pos < additionalChars) + { + Grow(additionalChars); + } + } + + /// Fallback for fast path in when there's not enough space in the destination. + /// The string to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowThenCopyString(string value) + { + Grow(value.Length); + value.AsSpan().CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + /// Fallback for for when not enough space exists in the current buffer. + /// The span to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowThenCopySpan(scoped ReadOnlySpan value) + { + Grow(value.Length); + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + /// Grows to have the capacity to store at least beyond . + [MethodImpl(MethodImplOptions.NoInlining)] // keep consumers as streamlined as possible + private void Grow(int additionalChars) + { + // This method is called when the remaining space (_chars.Length - _pos) is + // insufficient to store a specific number of additional characters. Thus, we + // need to grow to at least that new total. GrowCore will handle growing by more + // than that if possible. + Debug.Assert(additionalChars > _chars.Length - _pos); + GrowCore((uint)_pos + (uint)additionalChars); + } + + /// Grows the size of . + [MethodImpl(MethodImplOptions.NoInlining)] // keep consumers as streamlined as possible + private void Grow() + { + // This method is called when the remaining space in _chars isn't sufficient to continue + // the operation. Thus, we need at least one character beyond _chars.Length. GrowCore + // will handle growing by more than that if possible. + GrowCore((uint)_chars.Length + 1); + } + + /// Grow the size of to at least the specified . + [MethodImpl(MethodImplOptions.AggressiveInlining)] // but reuse this grow logic directly in both of the above grow routines + private void GrowCore(uint requiredMinCapacity) + { + // We want the max of how much space we actually required and doubling our capacity (without going beyond the max allowed length). We + // also want to avoid asking for small arrays, to reduce the number of times we need to grow, and since we're working with unsigned + // ints that could technically overflow if someone tried to, for example, append a huge string to a huge string, we also clamp to int.MaxValue. + // Even if the array creation fails in such a case, we may later fail in ToStringAndClear. + + const int StringMaxLength = 0x3FFFFFDF; + uint newCapacity = Math.Max(requiredMinCapacity, Math.Min((uint)_chars.Length * 2, StringMaxLength)); + int arraySize = (int)Clamp(newCapacity, MinimumArrayPoolLength, int.MaxValue); + + char[] newArray = ArrayPool.Shared.Rent(arraySize); + _chars.Slice(0, _pos).CopyTo(newArray); + + char[]? toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = newArray; + + if (toReturn is not null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + private static uint Clamp(uint value, uint min, uint max) + { + Debug.Assert(min <= max); + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Client/McpClient.cs b/src/ModelContextProtocol/Client/McpClient.cs index 2bcfacaa..e6f97e17 100644 --- a/src/ModelContextProtocol/Client/McpClient.cs +++ b/src/ModelContextProtocol/Client/McpClient.cs @@ -5,7 +5,6 @@ using ModelContextProtocol.Shared; using ModelContextProtocol.Utils.Json; using System.Text.Json; -using System.Threading; namespace ModelContextProtocol.Client; diff --git a/src/ModelContextProtocol/Client/McpClientExtensions.cs b/src/ModelContextProtocol/Client/McpClientExtensions.cs index 01d5f196..c6c98440 100644 --- a/src/ModelContextProtocol/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Client/McpClientExtensions.cs @@ -50,7 +50,7 @@ public static Task PingAsync(this IMcpClient client, CancellationToken cancellat parameters: null, McpJsonUtilities.JsonContext.Default.Object!, McpJsonUtilities.JsonContext.Default.Object, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken).AsTask(); } /// @@ -92,7 +92,7 @@ public static Task PingAsync(this IMcpClient client, CancellationToken cancellat /// /// /// is . - public static async Task> ListToolsAsync( + public static async ValueTask> ListToolsAsync( this IMcpClient client, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) @@ -205,7 +205,7 @@ public static async IAsyncEnumerable EnumerateToolsAsync( /// /// /// is . - public static async Task> ListPromptsAsync( + public static async ValueTask> ListPromptsAsync( this IMcpClient client, CancellationToken cancellationToken = default) { Throw.IfNull(client); @@ -311,7 +311,7 @@ public static async IAsyncEnumerable EnumeratePromptsAsync( /// /// Thrown when the prompt does not exist, when required arguments are missing, or when the server encounters an error processing the prompt. /// is . - public static Task GetPromptAsync( + public static ValueTask GetPromptAsync( this IMcpClient client, string name, IReadOnlyDictionary? arguments = null, @@ -349,12 +349,12 @@ public static Task GetPromptAsync( /// /// /// is . - public static async Task> ListResourceTemplatesAsync( + public static async ValueTask> ListResourceTemplatesAsync( this IMcpClient client, CancellationToken cancellationToken = default) { Throw.IfNull(client); - List? templates = null; + List? resourceTemplates = null; string? cursor = null; do @@ -366,20 +366,17 @@ public static async Task> ListResourceTemplatesAsync( McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult, cancellationToken: cancellationToken).ConfigureAwait(false); - if (templates is null) - { - templates = templateResults.ResourceTemplates; - } - else + resourceTemplates ??= new List(templateResults.ResourceTemplates.Count); + foreach (var template in templateResults.ResourceTemplates) { - templates.AddRange(templateResults.ResourceTemplates); + resourceTemplates.Add(new McpClientResourceTemplate(client, template)); } cursor = templateResults.NextCursor; } while (cursor is not null); - return templates; + return resourceTemplates; } /// @@ -395,7 +392,7 @@ public static async Task> ListResourceTemplatesAsync( /// with cursors if the server responds with templates split across multiple responses. /// /// - /// Every iteration through the returned + /// Every iteration through the returned /// will result in re-querying the server and yielding the sequence of available resource templates. /// /// @@ -409,7 +406,7 @@ public static async Task> ListResourceTemplatesAsync( /// /// /// is . - public static async IAsyncEnumerable EnumerateResourceTemplatesAsync( + public static async IAsyncEnumerable EnumerateResourceTemplatesAsync( this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) { Throw.IfNull(client); @@ -424,9 +421,9 @@ public static async IAsyncEnumerable EnumerateResourceTemplate McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult, cancellationToken: cancellationToken).ConfigureAwait(false); - foreach (var template in templateResults.ResourceTemplates) + foreach (var templateResult in templateResults.ResourceTemplates) { - yield return template; + yield return new McpClientResourceTemplate(client, templateResult); } cursor = templateResults.NextCursor; @@ -463,12 +460,12 @@ public static async IAsyncEnumerable EnumerateResourceTemplate /// /// /// is . - public static async Task> ListResourcesAsync( + public static async ValueTask> ListResourcesAsync( this IMcpClient client, CancellationToken cancellationToken = default) { Throw.IfNull(client); - List? resources = null; + List? resources = null; string? cursor = null; do @@ -480,13 +477,10 @@ public static async Task> ListResourcesAsync( McpJsonUtilities.JsonContext.Default.ListResourcesResult, cancellationToken: cancellationToken).ConfigureAwait(false); - if (resources is null) - { - resources = resourceResults.Resources; - } - else + resources ??= new List(resourceResults.Resources.Count); + foreach (var resource in resourceResults.Resources) { - resources.AddRange(resourceResults.Resources); + resources.Add(new McpClientResource(client, resource)); } cursor = resourceResults.NextCursor; @@ -509,7 +503,7 @@ public static async Task> ListResourcesAsync( /// with cursors if the server responds with resources split across multiple responses. /// /// - /// Every iteration through the returned + /// Every iteration through the returned /// will result in re-querying the server and yielding the sequence of available resources. /// /// @@ -523,7 +517,7 @@ public static async Task> ListResourcesAsync( /// /// /// is . - public static async IAsyncEnumerable EnumerateResourcesAsync( + public static async IAsyncEnumerable EnumerateResourcesAsync( this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) { Throw.IfNull(client); @@ -540,7 +534,7 @@ public static async IAsyncEnumerable EnumerateResourcesAsync( foreach (var resource in resourceResults.Resources) { - yield return resource; + yield return new McpClientResource(client, resource); } cursor = resourceResults.NextCursor; @@ -557,7 +551,7 @@ public static async IAsyncEnumerable EnumerateResourcesAsync( /// is . /// is . /// is empty or composed entirely of whitespace. - public static Task ReadResourceAsync( + public static ValueTask ReadResourceAsync( this IMcpClient client, string uri, CancellationToken cancellationToken = default) { Throw.IfNull(client); @@ -579,7 +573,7 @@ public static Task ReadResourceAsync( /// The to monitor for cancellation requests. The default is . /// is . /// is . - public static Task ReadResourceAsync( + public static ValueTask ReadResourceAsync( this IMcpClient client, Uri uri, CancellationToken cancellationToken = default) { Throw.IfNull(client); @@ -588,6 +582,31 @@ public static Task ReadResourceAsync( return ReadResourceAsync(client, uri.ToString(), cancellationToken); } + /// + /// Reads a resource from the server. + /// + /// The client instance used to communicate with the MCP server. + /// The uri template of the resource. + /// Arguments to use to format . + /// The to monitor for cancellation requests. The default is . + /// is . + /// is . + /// is empty or composed entirely of whitespace. + public static ValueTask ReadResourceAsync( + this IMcpClient client, string uriTemplate, IReadOnlyDictionary arguments, CancellationToken cancellationToken = default) + { + Throw.IfNull(client); + Throw.IfNullOrWhiteSpace(uriTemplate); + Throw.IfNull(arguments); + + return client.SendRequestAsync( + RequestMethods.ResourcesRead, + new() { Uri = UriTemplate.FormatUri(uriTemplate, arguments) }, + McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, + McpJsonUtilities.JsonContext.Default.ReadResourceResult, + cancellationToken: cancellationToken); + } + /// /// Requests completion suggestions for a prompt argument or resource reference. /// @@ -617,7 +636,7 @@ public static Task ReadResourceAsync( /// is . /// is empty or composed entirely of whitespace. /// The server returned an error response. - public static Task CompleteAsync(this IMcpClient client, Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default) + public static ValueTask CompleteAsync(this IMcpClient client, Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default) { Throw.IfNull(client); Throw.IfNull(reference); @@ -675,7 +694,7 @@ public static Task SubscribeToResourceAsync(this IMcpClient client, string uri, new() { Uri = uri }, McpJsonUtilities.JsonContext.Default.SubscribeRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken).AsTask(); } /// @@ -744,7 +763,7 @@ public static Task UnsubscribeFromResourceAsync(this IMcpClient client, string u new() { Uri = uri }, McpJsonUtilities.JsonContext.Default.UnsubscribeRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken).AsTask(); } /// @@ -813,7 +832,7 @@ public static Task UnsubscribeFromResourceAsync(this IMcpClient client, Uri uri, /// }); /// /// - public static Task CallToolAsync( + public static ValueTask CallToolAsync( this IMcpClient client, string toolName, IReadOnlyDictionary? arguments = null, @@ -842,7 +861,7 @@ public static Task CallToolAsync( McpJsonUtilities.JsonContext.Default.CallToolResponse, cancellationToken: cancellationToken); - static async Task SendRequestWithProgressAsync( + static async ValueTask SendRequestWithProgressAsync( IMcpClient client, string toolName, IReadOnlyDictionary? arguments, @@ -1061,7 +1080,7 @@ public static Task SetLoggingLevel(this IMcpClient client, LoggingLevel level, C new() { Level = level }, McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken).AsTask(); } /// diff --git a/src/ModelContextProtocol/Client/McpClientResource.cs b/src/ModelContextProtocol/Client/McpClientResource.cs new file mode 100644 index 00000000..8faec5a6 --- /dev/null +++ b/src/ModelContextProtocol/Client/McpClientResource.cs @@ -0,0 +1,64 @@ +using ModelContextProtocol.Protocol.Types; + +namespace ModelContextProtocol.Client; + +/// +/// Represents a named resource that can be retrieved from an MCP server. +/// +/// +/// +/// This class provides a client-side wrapper around a resource defined on an MCP server. It allows +/// retrieving the resource's content by sending a request to the server with the resource's URI. +/// Instances of this class are typically obtained by calling +/// or . +/// +/// +public sealed class McpClientResource +{ + private readonly IMcpClient _client; + + internal McpClientResource(IMcpClient client, Resource resource) + { + _client = client; + ProtocolResource = resource; + } + + /// Gets the underlying protocol type for this instance. + /// + /// + /// This property provides direct access to the underlying protocol representation of the resource, + /// which can be useful for advanced scenarios or when implementing custom MCP client extensions. + /// + /// + /// For most common use cases, you can use the more convenient and + /// properties instead of accessing the directly. + /// + /// + public Resource ProtocolResource { get; } + + /// Gets the URI of the resource. + public string Uri => ProtocolResource.Uri; + + /// Gets the name of the resource. + public string Name => ProtocolResource.Name; + + /// Gets a description of the resource. + public string? Description => ProtocolResource.Description; + + /// Gets a media (MIME) type of the resource. + public string? MimeType => ProtocolResource.MimeType; + + /// + /// Gets this resource's content by sending a request to the server. + /// + /// The to monitor for cancellation requests. The default is . + /// A containing the resource's result with content and messages. + /// + /// + /// This is a convenience method that internally calls . + /// + /// + public ValueTask ReadAsync( + CancellationToken cancellationToken = default) => + _client.ReadResourceAsync(Uri, cancellationToken); +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Client/McpClientResourceTemplate.cs b/src/ModelContextProtocol/Client/McpClientResourceTemplate.cs new file mode 100644 index 00000000..e764d551 --- /dev/null +++ b/src/ModelContextProtocol/Client/McpClientResourceTemplate.cs @@ -0,0 +1,64 @@ +using ModelContextProtocol.Protocol.Types; + +namespace ModelContextProtocol.Client; + +/// +/// Represents a named resource template that can be retrieved from an MCP server. +/// +/// +/// +/// This class provides a client-side wrapper around a resource template defined on an MCP server. It allows +/// retrieving the resource template's content by sending a request to the server with the resource's URI. +/// Instances of this class are typically obtained by calling +/// or . +/// +/// +public sealed class McpClientResourceTemplate +{ + private readonly IMcpClient _client; + + internal McpClientResourceTemplate(IMcpClient client, ResourceTemplate resourceTemplate) + { + _client = client; + ProtocolResourceTemplate = resourceTemplate; + } + + /// Gets the underlying protocol type for this instance. + /// + /// + /// This property provides direct access to the underlying protocol representation of the resource template, + /// which can be useful for advanced scenarios or when implementing custom MCP client extensions. + /// + /// + /// For most common use cases, you can use the more convenient and + /// properties instead of accessing the directly. + /// + /// + public ResourceTemplate ProtocolResourceTemplate { get; } + + /// Gets the URI template of the resource template. + public string UriTemplate => ProtocolResourceTemplate.UriTemplate; + + /// Gets the name of the resource template. + public string Name => ProtocolResourceTemplate.Name; + + /// Gets a description of the resource template. + public string? Description => ProtocolResourceTemplate.Description; + + /// Gets a media (MIME) type of the resource template. + public string? MimeType => ProtocolResourceTemplate.MimeType; + + /// + /// Gets this resource template's content by formatting a URI from the template and supplied arguments + /// and sending a request to the server. + /// + /// A dictionary of arguments to pass to the tool. Each key represents a parameter name, + /// and its associated value represents the argument value. + /// + /// The to monitor for cancellation requests. The default is . + /// A containing the resource template's result with content and messages. + public ValueTask ReadAsync( + IReadOnlyDictionary arguments, + CancellationToken cancellationToken = default) => + _client.ReadResourceAsync(UriTemplate, arguments, cancellationToken); +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Client/McpClientTool.cs b/src/ModelContextProtocol/Client/McpClientTool.cs index 91a0d8ab..2478f8cc 100644 --- a/src/ModelContextProtocol/Client/McpClientTool.cs +++ b/src/ModelContextProtocol/Client/McpClientTool.cs @@ -126,7 +126,7 @@ internal McpClientTool( /// }); /// /// - public Task CallAsync( + public ValueTask CallAsync( IReadOnlyDictionary? arguments = null, IProgress? progress = null, JsonSerializerOptions? serializerOptions = null, diff --git a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs index b389979e..0be6467b 100644 --- a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs @@ -297,6 +297,140 @@ where t.GetCustomAttribute() is not null } #endregion + #region WithResources + private const string WithResourcesRequiresUnreferencedCodeMessage = + $"The non-generic {nameof(WithResources)} and {nameof(WithResourcesFromAssembly)} methods require dynamic lookup of member metadata" + + $"and may not work in Native AOT. Use the generic {nameof(WithResources)} method instead."; + + /// Adds instances to the service collection backing . + /// The resource type. + /// The builder instance. + /// The builder provided in . + /// is . + /// + /// This method discovers all instance and static methods (public and non-public) on the specified + /// type, where the members are attributed as , and adds an + /// instance for each. For instance members, an instance will be constructed for each invocation of the resource. + /// + public static IMcpServerBuilder WithResources<[DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.PublicConstructors)] TResourceType>( + this IMcpServerBuilder builder) + { + Throw.IfNull(builder); + + foreach (var resourceTemplateMethod in typeof(TResourceType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (resourceTemplateMethod.GetCustomAttribute() is not null) + { + builder.Services.AddSingleton((Func)(resourceTemplateMethod.IsStatic ? + services => McpServerResource.Create(resourceTemplateMethod, options: new() { Services = services }) : + services => McpServerResource.Create(resourceTemplateMethod, typeof(TResourceType), new() { Services = services }))); + } + } + + return builder; + } + + /// Adds instances to the service collection backing . + /// The builder instance. + /// The instances to add to the server. + /// The builder provided in . + /// is . + /// is . + public static IMcpServerBuilder WithResources(this IMcpServerBuilder builder, IEnumerable resourcetemplates) + { + Throw.IfNull(builder); + Throw.IfNull(resourcetemplates); + + foreach (var resourceTemplate in resourcetemplates) + { + if (resourceTemplate is not null) + { + builder.Services.AddSingleton(resourceTemplate); + } + } + + return builder; + } + + /// Adds instances to the service collection backing . + /// The builder instance. + /// Types with marked methods to add as resources to the server. + /// The builder provided in . + /// is . + /// is . + /// + /// This method discovers all instance and static methods (public and non-public) on the specified + /// types, where the methods are attributed as , and adds an + /// instance for each. For instance methods, an instance will be constructed for each invocation of the resource. + /// + [RequiresUnreferencedCode(WithResourcesRequiresUnreferencedCodeMessage)] + public static IMcpServerBuilder WithResources(this IMcpServerBuilder builder, IEnumerable resourceTemplateTypes) + { + Throw.IfNull(builder); + Throw.IfNull(resourceTemplateTypes); + + foreach (var resourceTemplateType in resourceTemplateTypes) + { + if (resourceTemplateType is not null) + { + foreach (var resourceTemplateMethod in resourceTemplateType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (resourceTemplateMethod.GetCustomAttribute() is not null) + { + builder.Services.AddSingleton((Func)(resourceTemplateMethod.IsStatic ? + services => McpServerResource.Create(resourceTemplateMethod, options: new() { Services = services }) : + services => McpServerResource.Create(resourceTemplateMethod, resourceTemplateType, new() { Services = services }))); + } + } + } + } + + return builder; + } + + /// + /// Adds types marked with the attribute from the given assembly as resources to the server. + /// + /// The builder instance. + /// The assembly to load the types from. If , the calling assembly will be used. + /// The builder provided in . + /// is . + /// + /// + /// This method scans the specified assembly (or the calling assembly if none is provided) for classes + /// marked with the . It then discovers all members within those + /// classes that are marked with the and registers them as s + /// in the 's . + /// + /// + /// The method automatically handles both static and instance members. For instance members, a new instance + /// of the containing class will be constructed for each invocation of the resource. + /// + /// + /// Resource templates registered through this method can be discovered by clients using the list_resourcetemplates request + /// and invoked using the read_resource request. + /// + /// + /// Note that this method performs reflection at runtime and may not work in Native AOT scenarios. For + /// Native AOT compatibility, consider using the generic method instead. + /// + /// + [RequiresUnreferencedCode(WithResourcesRequiresUnreferencedCodeMessage)] + public static IMcpServerBuilder WithResourcesFromAssembly(this IMcpServerBuilder builder, Assembly? resourceAssembly = null) + { + Throw.IfNull(builder); + + resourceAssembly ??= Assembly.GetCallingAssembly(); + + return builder.WithResources( + from t in resourceAssembly.GetTypes() + where t.GetCustomAttribute() is not null + select t); + } + #endregion + #region Handlers /// /// Configures a handler for listing resource templates available from the Model Context Protocol server. diff --git a/src/ModelContextProtocol/McpEndpointExtensions.cs b/src/ModelContextProtocol/McpEndpointExtensions.cs index 3969fa33..d501d34d 100644 --- a/src/ModelContextProtocol/McpEndpointExtensions.cs +++ b/src/ModelContextProtocol/McpEndpointExtensions.cs @@ -36,7 +36,7 @@ public static class McpEndpointExtensions /// The options governing request serialization. /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous operation. The task result contains the deserialized result. - public static Task SendRequestAsync( + public static ValueTask SendRequestAsync( this IMcpEndpoint endpoint, string method, TParameters parameters, @@ -66,7 +66,7 @@ public static Task SendRequestAsync( /// The request id for the request. /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous operation. The task result contains the deserialized result. - internal static async Task SendRequestAsync( + internal static async ValueTask SendRequestAsync( this IMcpEndpoint endpoint, string method, TParameters parameters, diff --git a/src/ModelContextProtocol/Protocol/Transport/StdioClientSessionTransport.cs b/src/ModelContextProtocol/Protocol/Transport/StdioClientSessionTransport.cs index 015b7491..3bb5f312 100644 --- a/src/ModelContextProtocol/Protocol/Transport/StdioClientSessionTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/StdioClientSessionTransport.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol.Messages; -using System; using System.Diagnostics; namespace ModelContextProtocol.Protocol.Transport; diff --git a/src/ModelContextProtocol/Protocol/Transport/StreamableHttpPostTransport.cs b/src/ModelContextProtocol/Protocol/Transport/StreamableHttpPostTransport.cs index 4cdb30b3..764c3a8e 100644 --- a/src/ModelContextProtocol/Protocol/Transport/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/StreamableHttpPostTransport.cs @@ -1,7 +1,6 @@ using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Utils; using ModelContextProtocol.Utils.Json; -using System.Buffers; using System.IO.Pipelines; using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; diff --git a/src/ModelContextProtocol/Protocol/Types/Content.cs b/src/ModelContextProtocol/Protocol/Types/Content.cs index c98286cd..0ca1fbfe 100644 --- a/src/ModelContextProtocol/Protocol/Types/Content.cs +++ b/src/ModelContextProtocol/Protocol/Types/Content.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.AI; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol.Types; diff --git a/src/ModelContextProtocol/Protocol/Types/CreateMessageRequestParams.cs b/src/ModelContextProtocol/Protocol/Types/CreateMessageRequestParams.cs index 338ec3ad..bb7cd8a6 100644 --- a/src/ModelContextProtocol/Protocol/Types/CreateMessageRequestParams.cs +++ b/src/ModelContextProtocol/Protocol/Types/CreateMessageRequestParams.cs @@ -1,6 +1,5 @@ using ModelContextProtocol.Protocol.Messages; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol.Types; diff --git a/src/ModelContextProtocol/Protocol/Types/ListRootsResult.cs b/src/ModelContextProtocol/Protocol/Types/ListRootsResult.cs index 53df9753..8928954c 100644 --- a/src/ModelContextProtocol/Protocol/Types/ListRootsResult.cs +++ b/src/ModelContextProtocol/Protocol/Types/ListRootsResult.cs @@ -1,6 +1,5 @@ using ModelContextProtocol.Protocol.Messages; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol.Types; diff --git a/src/ModelContextProtocol/Protocol/Types/LoggingCapability.cs b/src/ModelContextProtocol/Protocol/Types/LoggingCapability.cs index 18e60c42..a8574013 100644 --- a/src/ModelContextProtocol/Protocol/Types/LoggingCapability.cs +++ b/src/ModelContextProtocol/Protocol/Types/LoggingCapability.cs @@ -1,4 +1,3 @@ -using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Server; using System.Text.Json.Serialization; diff --git a/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs b/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs index ae75d1a1..17475f8c 100644 --- a/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs +++ b/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs @@ -16,5 +16,5 @@ public class ReadResourceRequestParams : RequestParams /// The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. /// [JsonPropertyName("uri")] - public string? Uri { get; init; } + public required string Uri { get; init; } } diff --git a/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs b/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs index e0fa19e0..0c392db1 100644 --- a/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs +++ b/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs @@ -44,7 +44,6 @@ private protected ResourceContents() [JsonPropertyName("mimeType")] public string? MimeType { get; set; } - /// /// Provides a for . /// diff --git a/src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs b/src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs index 77f8f2cb..8431857e 100644 --- a/src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs +++ b/src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs @@ -67,4 +67,27 @@ public record ResourceTemplate /// [JsonPropertyName("annotations")] public Annotations? Annotations { get; init; } + + /// Gets whether contains any template expressions. + [JsonIgnore] + public bool IsTemplated => UriTemplate.Contains('{'); + + /// Converts the into a . + /// A if is ; otherwise, . + public Resource? AsResource() + { + if (IsTemplated) + { + return null; + } + + return new() + { + Uri = UriTemplate, + Name = Name, + Description = Description, + MimeType = MimeType, + Annotations = Annotations, + }; + } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs b/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs index 3bb76378..e93b8d99 100644 --- a/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs +++ b/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs @@ -87,4 +87,22 @@ public class ResourcesCapability /// [JsonIgnore] public Func, CancellationToken, ValueTask>? UnsubscribeFromResourcesHandler { get; set; } + + /// + /// Gets or sets a collection of resources served by the server. + /// + /// + /// + /// Resources specified via augment the , + /// and handlers, if provided. Resources with template expressions in their URI templates are considered resource templates + /// and are listed via ListResourceTemplate, whereas resources without template parameters are considered static resources and are listed with ListResources. + /// + /// + /// ReadResource requests will first check the for the exact resource being requested. If no match is found, they'll proceed to + /// try to match the resource against each resource template in . If no match is still found, the request will fall back to + /// any handler registered for . + /// + /// + [JsonIgnore] + public McpServerPrimitiveCollection? ResourceCollection { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs index bf63e433..4c5da534 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Utils; using ModelContextProtocol.Utils.Json; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; @@ -27,7 +27,7 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt } /// - /// Creates an instance for a method, specified via a instance. + /// Creates an instance for a method, specified via a instance. /// public static new AIFunctionMcpServerPrompt Create( MethodInfo method, @@ -44,7 +44,7 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt } /// - /// Creates an instance for a method, specified via a instance. + /// Creates an instance for a method, specified via a instance. /// public static new AIFunctionMcpServerPrompt Create( MethodInfo method, @@ -89,6 +89,27 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( }; } + if (pi.ParameterType == typeof(IProgress)) + { + // Bind IProgress to the progress token in the request, + // if there is one. If we can't get one, return a nop progress. + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => + { + var requestContent = GetRequestContext(args); + if (requestContent?.Server is { } server && + requestContent?.Params?.Meta?.ProgressToken is { } progressToken) + { + return new TokenProgress(server, progressToken); + } + + return NullProgress.Instance; + }, + }; + } + return default; static RequestContext? GetRequestContext(AIFunctionArguments args) @@ -138,13 +159,18 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( return new AIFunctionMcpServerPrompt(function, prompt); } - private static McpServerPromptCreateOptions? DeriveOptions(MethodInfo method, McpServerPromptCreateOptions? options) + private static McpServerPromptCreateOptions DeriveOptions(MethodInfo method, McpServerPromptCreateOptions? options) { McpServerPromptCreateOptions newOptions = options?.Clone() ?? new(); - if (method.GetCustomAttribute() is { } attr) + if (method.GetCustomAttribute() is { } promptAttr) + { + newOptions.Name ??= promptAttr.Name; + } + + if (method.GetCustomAttribute() is { } descAttr) { - newOptions.Name ??= attr.Name; + newOptions.Description ??= descAttr.Description; } return newOptions; @@ -167,18 +193,6 @@ private AIFunctionMcpServerPrompt(AIFunction function, Prompt prompt) public override Prompt ProtocolPrompt { get; } /// - /// - /// This implementation invokes the underlying with the request arguments, and processes - /// the result to create a standardized . The method supports various return types from - /// the underlying function: - /// - /// Direct instances are returned as-is - /// String values are converted to a single user message - /// Single objects are wrapped in a result - /// Collections of objects are combined in a result - /// objects are converted to prompt messages - /// - /// public override async ValueTask GetAsync( RequestContext request, CancellationToken cancellationToken = default) { diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs new file mode 100644 index 00000000..6ba2a0fd --- /dev/null +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs @@ -0,0 +1,430 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Protocol.Types; +using ModelContextProtocol.Utils; +using ModelContextProtocol.Utils.Json; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ModelContextProtocol.Server; + +/// Provides an that's implemented via an . +internal sealed class AIFunctionMcpServerResource : McpServerResource +{ + private readonly Regex? _uriParser; + private readonly string[] _templateVariableNames = []; + + /// + /// Creates an instance for a method, specified via a instance. + /// + public static new AIFunctionMcpServerResource Create( + Delegate method, + McpServerResourceCreateOptions? options) + { + Throw.IfNull(method); + + options = DeriveOptions(method.Method, options); + + return Create(method.Method, method.Target, options); + } + + /// + /// Creates an instance for a method, specified via a instance. + /// + public static new AIFunctionMcpServerResource Create( + MethodInfo method, + object? target, + McpServerResourceCreateOptions? options) + { + Throw.IfNull(method); + + options = DeriveOptions(method, options); + + return Create( + AIFunctionFactory.Create(method, target, CreateAIFunctionFactoryOptions(method, options)), + options); + } + + /// + /// Creates an instance for a method, specified via a instance. + /// + public static new AIFunctionMcpServerResource Create( + MethodInfo method, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType, + McpServerResourceCreateOptions? options) + { + Throw.IfNull(method); + + options = DeriveOptions(method, options); + + return Create( + AIFunctionFactory.Create(method, targetType, CreateAIFunctionFactoryOptions(method, options)), + options); + } + + private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( + MethodInfo method, McpServerResourceCreateOptions? options) => + new() + { + Name = options?.Name ?? method.GetCustomAttribute()?.Name, + Description = options?.Description, + MarshalResult = static (result, _, cancellationToken) => new ValueTask(result), + SerializerOptions = McpJsonUtilities.DefaultOptions, + Services = options?.Services, + ConfigureParameterBinding = pi => + { + if (pi.ParameterType == typeof(RequestContext)) + { + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => GetRequestContext(args), + }; + } + + if (pi.ParameterType == typeof(IMcpServer)) + { + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => GetRequestContext(args)?.Server, + }; + } + + if (pi.ParameterType == typeof(IProgress)) + { + // Bind IProgress to the progress token in the request, + // if there is one. If we can't get one, return a nop progress. + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => + { + var requestContent = GetRequestContext(args); + if (requestContent?.Server is { } server && + requestContent?.Params?.Meta?.ProgressToken is { } progressToken) + { + return new TokenProgress(server, progressToken); + } + + return NullProgress.Instance; + }, + }; + } + + // These parameters are the ones and only ones to include in the schema. The schema + // won't be consumed by anyone other than this instance, which will use it to determine + // which properties should show up in the URI template. + if (pi.Name is not null && GetConverter(pi.ParameterType) is { } converter) + { + return new() + { + ExcludeFromSchema = false, + BindParameter = (pi, args) => + { + if (args.TryGetValue(pi.Name!, out var value)) + { + return + value is null || pi.ParameterType.IsInstanceOfType(value) ? value : + value is string stringValue ? converter(stringValue) : + throw new ArgumentException($"Parameter '{pi.Name}' is of type '{pi.ParameterType}', but value '{value}' is of type '{value.GetType()}'."); + } + + return + pi.HasDefaultValue ? pi.DefaultValue : + throw new ArgumentException($"Missing a value for the required parameter '{pi.Name}'."); + }, + }; + } + + return default; + + static RequestContext? GetRequestContext(AIFunctionArguments args) + { + if (args.Context?.TryGetValue(typeof(RequestContext), out var rc) is true && + rc is RequestContext requestContext) + { + return requestContext; + } + + return null; + } + }, + }; + + private static readonly ConcurrentDictionary> s_convertersCache = []; + + private static Func? GetConverter(Type type) + { + Type key = type; + + if (s_convertersCache.TryGetValue(key, out var converter)) + { + return converter; + } + + if (Nullable.GetUnderlyingType(type) is { } underlyingType) + { + // We will have already screened out null values by the time the converter is used, + // so we can parse just the underlying type. + type = underlyingType; + } + + if (type == typeof(string) || type == typeof(object)) converter = static s => s; + if (type == typeof(bool)) converter = static s => bool.Parse(s); + if (type == typeof(char)) converter = static s => char.Parse(s); + if (type == typeof(byte)) converter = static s => byte.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(sbyte)) converter = static s => sbyte.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(ushort)) converter = static s => ushort.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(short)) converter = static s => short.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(uint)) converter = static s => uint.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(int)) converter = static s => int.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(ulong)) converter = static s => ulong.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(long)) converter = static s => long.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(float)) converter = static s => float.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(double)) converter = static s => double.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(decimal)) converter = static s => decimal.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(TimeSpan)) converter = static s => TimeSpan.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(DateTime)) converter = static s => DateTime.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(DateTimeOffset)) converter = static s => DateTimeOffset.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(Uri)) converter = static s => new Uri(s, UriKind.RelativeOrAbsolute); + if (type == typeof(Guid)) converter = static s => Guid.Parse(s); + if (type == typeof(Version)) converter = static s => Version.Parse(s); +#if NET + if (type == typeof(Half)) converter = static s => Half.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(Int128)) converter = static s => Int128.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(UInt128)) converter = static s => UInt128.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(IntPtr)) converter = static s => IntPtr.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(UIntPtr)) converter = static s => UIntPtr.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(DateOnly)) converter = static s => DateOnly.Parse(s, CultureInfo.InvariantCulture); + if (type == typeof(TimeOnly)) converter = static s => TimeOnly.Parse(s, CultureInfo.InvariantCulture); +#endif + if (type.IsEnum) converter = s => Enum.Parse(type, s); + + if (type.GetCustomAttribute() is TypeConverterAttribute tca && + Type.GetType(tca.ConverterTypeName, throwOnError: false) is { } converterType && + Activator.CreateInstance(converterType) is TypeConverter typeConverter && + typeConverter.CanConvertFrom(typeof(string))) + { + converter = s => typeConverter.ConvertFrom(null, CultureInfo.InvariantCulture, s); + } + + if (converter is not null) + { + s_convertersCache.TryAdd(key, converter); + } + + return converter; + } + + /// Creates an that wraps the specified . + public static new AIFunctionMcpServerResource Create(AIFunction function, McpServerResourceCreateOptions? options) + { + Throw.IfNull(function); + + string name = options?.Name ?? function.Name; + + ResourceTemplate resource = new() + { + UriTemplate = options?.UriTemplate ?? DeriveUriTemplate(name, function), + Name = name, + Description = options?.Description, + MimeType = options?.MimeType, + }; + + return new AIFunctionMcpServerResource(function, resource); + } + + private static McpServerResourceCreateOptions DeriveOptions(MemberInfo member, McpServerResourceCreateOptions? options) + { + McpServerResourceCreateOptions newOptions = options?.Clone() ?? new(); + + if (member.GetCustomAttribute() is { } resourceAttr) + { + newOptions.UriTemplate ??= resourceAttr.UriTemplate; + newOptions.Name ??= resourceAttr.Name; + newOptions.MimeType ??= resourceAttr.MimeType; + } + + if (member.GetCustomAttribute() is { } descAttr) + { + newOptions.Description ??= descAttr.Description; + } + + return newOptions; + } + + /// Derives a name to be used as a resource name. + private static string DeriveUriTemplate(string name, AIFunction function) + { + StringBuilder template = new(); + + template.Append("resource://").Append(Uri.EscapeDataString(name)); + + if (function.JsonSchema.TryGetProperty("properties", out JsonElement properties)) + { + string separator = "{?"; + foreach (var prop in properties.EnumerateObject()) + { + template.Append(separator).Append(prop.Name); + separator = ","; + } + + if (separator == ",") + { + template.Append('}'); + } + } + + return template.ToString(); + } + + /// Gets the wrapped by this resource. + internal AIFunction AIFunction { get; } + + /// Initializes a new instance of the class. + private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resourceTemplate) + { + AIFunction = function; + ProtocolResourceTemplate = resourceTemplate; + ProtocolResource = resourceTemplate.AsResource(); + + if (ProtocolResource is null) + { + _uriParser = UriTemplate.CreateParser(resourceTemplate.UriTemplate); + _templateVariableNames = _uriParser.GetGroupNames().Where(n => n != "0").ToArray(); + } + } + + /// + public override string ToString() => AIFunction.ToString(); + + /// + public override ResourceTemplate ProtocolResourceTemplate { get; } + + /// + public override Resource? ProtocolResource { get; } + + /// + public override async ValueTask ReadAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + Throw.IfNull(request); + Throw.IfNull(request.Params); + Throw.IfNull(request.Params.Uri); + + cancellationToken.ThrowIfCancellationRequested(); + + // Check to see if this URI template matches the request URI. If it doesn't, return null. + // For templates, use the Regex to parse. For static resources, we can just compare the URIs. + Match? match = null; + if (_uriParser is not null) + { + match = _uriParser.Match(request.Params.Uri); + if (!match.Success) + { + return null; + } + } + else if (request.Params.Uri != ProtocolResource!.Uri) + { + return null; + } + + // Build up the arguments for the AIFunction call, including all of the name/value pairs from the URI. + AIFunctionArguments arguments = new() + { + Services = request.Services, + Context = new Dictionary() { [typeof(RequestContext)] = request } + }; + + // For templates, populate the arguments from the URI template. + if (match is not null) + { + foreach (string varName in _templateVariableNames) + { + if (match.Groups[varName] is { Success: true } value) + { + arguments[varName] = Uri.UnescapeDataString(value.Value); + } + } + } + + // Invoke the function. + object? result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + + // And process the result. + return result switch + { + ReadResourceResult readResourceResult => readResourceResult, + + ResourceContents content => new() + { + Contents = [content], + }, + + TextContent tc => new() + { + Contents = [new TextResourceContents() { Uri = request.Params!.Uri, MimeType = ProtocolResourceTemplate.MimeType, Text = tc.Text }], + }, + + DataContent dc => new() + { + Contents = [new BlobResourceContents() { Uri = request.Params!.Uri, MimeType = dc.MediaType, Blob = dc.GetBase64Data() }], + }, + + string text => new() + { + Contents = [new TextResourceContents() { Uri = request.Params!.Uri, MimeType = ProtocolResourceTemplate.MimeType, Text = text }], + }, + + IEnumerable contents => new() + { + Contents = contents.ToList(), + }, + + IEnumerable aiContents => new() + { + Contents = aiContents.Select( + ac => ac switch + { + TextContent tc => new TextResourceContents() + { + Uri = request.Params!.Uri, + MimeType = ProtocolResourceTemplate.MimeType, + Text = tc.Text + }, + + DataContent dc => new BlobResourceContents() + { + Uri = request.Params!.Uri, + MimeType = dc.MediaType, + Blob = dc.GetBase64Data() + }, + + _ => throw new InvalidOperationException($"Unsupported AIContent type '{ac.GetType()}' returned from resource function."), + }).ToList(), + }, + + IEnumerable strings => new() + { + Contents = strings.Select(text => new TextResourceContents() + { + Uri = request.Params!.Uri, + MimeType = ProtocolResourceTemplate.MimeType, + Text = text + }).ToList(), + }, + + null => throw new InvalidOperationException("Null result returned from resource function."), + + _ => throw new InvalidOperationException($"Unsupported result type '{result.GetType()}' returned from resource function."), + }; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs index 8d31f01b..d892039c 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Utils; using ModelContextProtocol.Utils.Json; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; @@ -27,7 +27,7 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool } /// - /// Creates an instance for a method, specified via a instance. + /// Creates an instance for a method, specified via a instance. /// public static new AIFunctionMcpServerTool Create( MethodInfo method, @@ -44,7 +44,7 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool } /// - /// Creates an instance for a method, specified via a instance. + /// Creates an instance for a method, specified via a instance. /// public static new AIFunctionMcpServerTool Create( MethodInfo method, @@ -160,36 +160,41 @@ options.OpenWorld is not null || return new AIFunctionMcpServerTool(function, tool); } - private static McpServerToolCreateOptions? DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) + private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) { McpServerToolCreateOptions newOptions = options?.Clone() ?? new(); - if (method.GetCustomAttribute() is { } attr) + if (method.GetCustomAttribute() is { } toolAttr) { - newOptions.Name ??= attr.Name; - newOptions.Title ??= attr.Title; + newOptions.Name ??= toolAttr.Name; + newOptions.Title ??= toolAttr.Title; - if (attr._destructive is bool destructive) + if (toolAttr._destructive is bool destructive) { newOptions.Destructive ??= destructive; } - if (attr._idempotent is bool idempotent) + if (toolAttr._idempotent is bool idempotent) { newOptions.Idempotent ??= idempotent; } - if (attr._openWorld is bool openWorld) + if (toolAttr._openWorld is bool openWorld) { newOptions.OpenWorld ??= openWorld; } - if (attr._readOnly is bool readOnly) + if (toolAttr._readOnly is bool readOnly) { newOptions.ReadOnly ??= readOnly; } } + if (method.GetCustomAttribute() is { } descAttr) + { + newOptions.Description ??= descAttr.Description; + } + return newOptions; } diff --git a/src/ModelContextProtocol/Server/DelegatingMcpServerResource.cs b/src/ModelContextProtocol/Server/DelegatingMcpServerResource.cs new file mode 100644 index 00000000..92f1ee40 --- /dev/null +++ b/src/ModelContextProtocol/Server/DelegatingMcpServerResource.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Protocol.Types; +using ModelContextProtocol.Utils; + +namespace ModelContextProtocol.Server; + +/// Provides an that delegates all operations to an inner . +/// +/// This is recommended as a base type when building resources that can be chained around an underlying . +/// The default implementation simply passes each call to the inner resource instance. +/// +public abstract class DelegatingMcpServerResource : McpServerResource +{ + private readonly McpServerResource _innerResource; + + /// Initializes a new instance of the class around the specified . + /// The inner resource wrapped by this delegating resource. + protected DelegatingMcpServerResource(McpServerResource innerResource) + { + Throw.IfNull(innerResource); + _innerResource = innerResource; + } + + /// + public override Resource? ProtocolResource => _innerResource.ProtocolResource; + + /// + public override ResourceTemplate ProtocolResourceTemplate => _innerResource.ProtocolResourceTemplate; + + /// + public override ValueTask ReadAsync(RequestContext request, CancellationToken cancellationToken = default) => + _innerResource.ReadAsync(request, cancellationToken); + + /// + public override string ToString() => _innerResource.ToString(); +} diff --git a/src/ModelContextProtocol/Server/IMcpServerPrimitive.cs b/src/ModelContextProtocol/Server/IMcpServerPrimitive.cs index d3cf5d4a..597fdec9 100644 --- a/src/ModelContextProtocol/Server/IMcpServerPrimitive.cs +++ b/src/ModelContextProtocol/Server/IMcpServerPrimitive.cs @@ -5,6 +5,6 @@ namespace ModelContextProtocol.Server; /// public interface IMcpServerPrimitive { - /// Gets the name of the primitive. - string Name { get; } + /// Gets the unique identifier of the primitive. + string Id { get; } } diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs index ae0e7afc..16311c34 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -22,9 +22,7 @@ internal sealed class McpServer : McpEndpoint, IMcpServer private readonly ITransport _sessionTransport; private readonly bool _servicesScopePerRequest; - - private readonly EventHandler? _toolsChangedDelegate; - private readonly EventHandler? _promptsChangedDelegate; + private readonly List _disposables = []; private string _endpointName; private int _started; @@ -60,13 +58,15 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory? _servicesScopePerRequest = options.ScopeRequests; // Configure all request handlers based on the supplied options. - SetInitializeHandler(options); - SetToolsHandler(options); - SetPromptsHandler(options); - SetResourcesHandler(options); - SetSetLoggingLevelHandler(options); - SetCompletionHandler(options); - SetPingHandler(); + ServerCapabilities = new(); + ConfigureInitialize(options); + ConfigureTools(options); + ConfigurePrompts(options); + ConfigureResources(options); + ConfigureLogging(options); + ConfigureCompletion(options); + ConfigureExperimental(options); + ConfigurePing(); // Register any notification handlers that were provided. if (options.Capabilities?.NotificationHandlers is { } notificationHandlers) @@ -77,29 +77,31 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory? // Now that everything has been configured, subscribe to any necessary notifications. if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools) { - _toolsChangedDelegate = delegate - { - _ = SendMessageAsync(new JsonRpcNotification() { Method = NotificationMethods.ToolListChangedNotification }); - }; - - tools.Changed += _toolsChangedDelegate; + EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification); + tools.Changed += changed; + _disposables.Add(() => tools.Changed -= changed); } if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts) { - _promptsChangedDelegate = delegate - { - _ = SendMessageAsync(new JsonRpcNotification() { Method = NotificationMethods.PromptListChangedNotification }); - }; + EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification); + prompts.Changed += changed; + _disposables.Add(() => prompts.Changed -= changed); + } - prompts.Changed += _promptsChangedDelegate; + var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection; + if (resources is not null) + { + EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification); + resources.Changed += changed; + _disposables.Add(() => resources.Changed -= changed); } // And initialize the session. InitializeSession(transport); } - public ServerCapabilities? ServerCapabilities { get; set; } + public ServerCapabilities ServerCapabilities { get; } = new(); /// public ClientCapabilities? ClientCapabilities { get; set; } @@ -140,22 +142,11 @@ public async Task RunAsync(CancellationToken cancellationToken = default) public override async ValueTask DisposeUnsynchronizedAsync() { - if (_toolsChangedDelegate is not null && - ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools) - { - tools.Changed -= _toolsChangedDelegate; - } - - if (_promptsChangedDelegate is not null && - ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts) - { - prompts.Changed -= _promptsChangedDelegate; - } - + _disposables.ForEach(d => d()); await base.DisposeUnsynchronizedAsync().ConfigureAwait(false); } - private void SetPingHandler() + private void ConfigurePing() { SetHandler(RequestMethods.Ping, async (request, _) => new PingResult(), @@ -163,7 +154,7 @@ private void SetPingHandler() McpJsonUtilities.JsonContext.Default.PingResult); } - private void SetInitializeHandler(McpServerOptions options) + private void ConfigureInitialize(McpServerOptions options) { RequestHandlers.Set(RequestMethods.Initialize, async (request, _, _) => @@ -187,45 +178,126 @@ private void SetInitializeHandler(McpServerOptions options) McpJsonUtilities.JsonContext.Default.InitializeResult); } - private void SetCompletionHandler(McpServerOptions options) + private void ConfigureCompletion(McpServerOptions options) { if (options.Capabilities?.Completions is not { } completionsCapability) { return; } - var completeHandler = completionsCapability.CompleteHandler ?? - throw new InvalidOperationException( - $"{nameof(ServerCapabilities)}.{nameof(ServerCapabilities.Completions)} was enabled, " + - $"but {nameof(CompletionsCapability.CompleteHandler)} was not specified."); + ServerCapabilities.Completions = new() + { + CompleteHandler = completionsCapability.CompleteHandler ?? (static async (_, __) => new CompleteResult()) + }; - // This capability is not optional, so return an empty result if there is no handler. SetHandler( RequestMethods.CompletionComplete, - completeHandler, + ServerCapabilities.Completions.CompleteHandler, McpJsonUtilities.JsonContext.Default.CompleteRequestParams, McpJsonUtilities.JsonContext.Default.CompleteResult); } - private void SetResourcesHandler(McpServerOptions options) + private void ConfigureExperimental(McpServerOptions options) + { + ServerCapabilities.Experimental = options.Capabilities?.Experimental; + } + + private void ConfigureResources(McpServerOptions options) { if (options.Capabilities?.Resources is not { } resourcesCapability) { return; } - var listResourcesHandler = resourcesCapability.ListResourcesHandler; - var listResourceTemplatesHandler = resourcesCapability.ListResourceTemplatesHandler; + ServerCapabilities.Resources = new(); + + var listResourcesHandler = resourcesCapability.ListResourcesHandler ?? (static async (_, __) => new ListResourcesResult()); + var listResourceTemplatesHandler = resourcesCapability.ListResourceTemplatesHandler ?? (static async (_, __) => new ListResourceTemplatesResult()); + var readResourceHandler = resourcesCapability.ReadResourceHandler ?? (static async (request, _) => throw new McpException($"Unknown resource URI: '{request.Params?.Uri}'", McpErrorCode.InvalidParams)); + var subscribeHandler = resourcesCapability.SubscribeToResourcesHandler ?? (static async (_, __) => new EmptyResult()); + var unsubscribeHandler = resourcesCapability.UnsubscribeFromResourcesHandler ?? (static async (_, __) => new EmptyResult()); + var resources = resourcesCapability.ResourceCollection; + var listChanged = resourcesCapability.ListChanged; + var subcribe = resourcesCapability.Subscribe; - if ((listResourcesHandler is not { } && listResourceTemplatesHandler is not { }) || - resourcesCapability.ReadResourceHandler is not { } readResourceHandler) + // Handle resources provided via DI. + if (resources is { IsEmpty: false }) { - throw new InvalidOperationException( - $"{nameof(ServerCapabilities)}.{nameof(ServerCapabilities.Resources)} was enabled, " + - $"but {nameof(ResourcesCapability.ListResourcesHandler)} or {nameof(ResourcesCapability.ReadResourceHandler)} was not specified."); + var originalListResourcesHandler = listResourcesHandler; + listResourcesHandler = async (request, cancellationToken) => + { + ListResourcesResult result = originalListResourcesHandler is not null ? + await originalListResourcesHandler(request, cancellationToken).ConfigureAwait(false) : + new(); + + if (request.Params?.Cursor is null) + { + result.Resources.AddRange(resources.Select(t => t.ProtocolResource).OfType()); + } + + return result; + }; + + var originalListResourceTemplatesHandler = listResourceTemplatesHandler; + listResourceTemplatesHandler = async (request, cancellationToken) => + { + ListResourceTemplatesResult result = originalListResourceTemplatesHandler is not null ? + await originalListResourceTemplatesHandler(request, cancellationToken).ConfigureAwait(false) : + new(); + + if (request.Params?.Cursor is null) + { + result.ResourceTemplates.AddRange(resources.Where(t => t.IsTemplated).Select(t => t.ProtocolResourceTemplate)); + } + + return result; + }; + + // Synthesize read resource handler, which covers both resources and resource templates. + var originalReadResourceHandler = readResourceHandler; + readResourceHandler = async (request, cancellationToken) => + { + if (request.Params?.Uri is string uri) + { + // First try an O(1) lookup by exact match. + if (resources.TryGetPrimitive(uri, out var resource)) + { + if (await resource.ReadAsync(request, cancellationToken).ConfigureAwait(false) is { } result) + { + return result; + } + } + + // Fall back to an O(N) lookup, trying to match against each URI template. + // The number of templates is controlled by the server developer, and the number is expected to be + // not terribly large. If that changes, this can be tweaked to enable a more efficient lookup. + foreach (var resourceTemplate in resources) + { + if (await resourceTemplate.ReadAsync(request, cancellationToken).ConfigureAwait(false) is { } result) + { + return result; + } + } + } + + // Finally fall back to the handler. + return await originalReadResourceHandler(request, cancellationToken).ConfigureAwait(false); + }; + + listChanged = true; + + // TODO: Implement subscribe/unsubscribe logic for resource and resource template collections. + // subcribe = true; } - listResourcesHandler ??= static async (_, _) => new ListResourcesResult(); + ServerCapabilities.Resources.ListResourcesHandler = listResourcesHandler; + ServerCapabilities.Resources.ListResourceTemplatesHandler = listResourceTemplatesHandler; + ServerCapabilities.Resources.ReadResourceHandler = readResourceHandler; + ServerCapabilities.Resources.ResourceCollection = resources; + ServerCapabilities.Resources.SubscribeToResourcesHandler = subscribeHandler; + ServerCapabilities.Resources.UnsubscribeFromResourcesHandler = unsubscribeHandler; + ServerCapabilities.Resources.ListChanged = listChanged; + ServerCapabilities.Resources.Subscribe = subcribe; SetHandler( RequestMethods.ResourcesList, @@ -233,32 +305,17 @@ private void SetResourcesHandler(McpServerOptions options) McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourcesResult); - SetHandler( - RequestMethods.ResourcesRead, - readResourceHandler, - McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, - McpJsonUtilities.JsonContext.Default.ReadResourceResult); - - listResourceTemplatesHandler ??= static async (_, _) => new ListResourceTemplatesResult(); SetHandler( RequestMethods.ResourcesTemplatesList, listResourceTemplatesHandler, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult); - if (resourcesCapability.Subscribe is not true) - { - return; - } - - var subscribeHandler = resourcesCapability.SubscribeToResourcesHandler; - var unsubscribeHandler = resourcesCapability.UnsubscribeFromResourcesHandler; - if (subscribeHandler is null || unsubscribeHandler is null) - { - throw new InvalidOperationException( - $"{nameof(ServerCapabilities)}.{nameof(ServerCapabilities.Resources)}.{nameof(ResourcesCapability.Subscribe)} is set, " + - $"but {nameof(ResourcesCapability.SubscribeToResourcesHandler)} or {nameof(ResourcesCapability.UnsubscribeFromResourcesHandler)} was not specified."); - } + SetHandler( + RequestMethods.ResourcesRead, + readResourceHandler, + McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, + McpJsonUtilities.JsonContext.Default.ReadResourceResult); SetHandler( RequestMethods.ResourcesSubscribe, @@ -273,25 +330,23 @@ private void SetResourcesHandler(McpServerOptions options) McpJsonUtilities.JsonContext.Default.EmptyResult); } - private void SetPromptsHandler(McpServerOptions options) + private void ConfigurePrompts(McpServerOptions options) { - PromptsCapability? promptsCapability = options.Capabilities?.Prompts; - var listPromptsHandler = promptsCapability?.ListPromptsHandler; - var getPromptHandler = promptsCapability?.GetPromptHandler; - var prompts = promptsCapability?.PromptCollection; - - if (listPromptsHandler is null != getPromptHandler is null) + if (options.Capabilities?.Prompts is not { } promptsCapability) { - throw new InvalidOperationException( - $"{nameof(PromptsCapability)}.{nameof(promptsCapability.ListPromptsHandler)} or " + - $"{nameof(PromptsCapability)}.{nameof(promptsCapability.GetPromptHandler)} was specified without the other. " + - $"Both or neither must be provided."); + return; } - // Handle prompts provided via DI. + ServerCapabilities.Prompts = new(); + + var listPromptsHandler = promptsCapability.ListPromptsHandler ?? (static async (_, __) => new ListPromptsResult()); + var getPromptHandler = promptsCapability.GetPromptHandler ?? (static async (request, _) => throw new McpException($"Unknown prompt: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); + var prompts = promptsCapability.PromptCollection; + var listChanged = promptsCapability.ListChanged; + + // Handle tools provided via DI by augmenting the handlers to incorporate them. if (prompts is { IsEmpty: false }) { - // Synthesize the handlers, making sure a PromptsCapability is specified. var originalListPromptsHandler = listPromptsHandler; listPromptsHandler = async (request, cancellationToken) => { @@ -310,53 +365,22 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals var originalGetPromptHandler = getPromptHandler; getPromptHandler = (request, cancellationToken) => { - if (request.Params is null || - !prompts.TryGetPrimitive(request.Params.Name, out var prompt)) + if (request.Params is not null && + prompts.TryGetPrimitive(request.Params.Name, out var prompt)) { - if (originalGetPromptHandler is not null) - { - return originalGetPromptHandler(request, cancellationToken); - } - - throw new McpException($"Unknown prompt: '{request.Params?.Name}'", McpErrorCode.InvalidParams); + return prompt.GetAsync(request, cancellationToken); } - return prompt.GetAsync(request, cancellationToken); + return originalGetPromptHandler(request, cancellationToken); }; - ServerCapabilities = new() - { - Experimental = options.Capabilities?.Experimental, - Logging = options.Capabilities?.Logging, - Tools = options.Capabilities?.Tools, - Resources = options.Capabilities?.Resources, - Prompts = new() - { - ListPromptsHandler = listPromptsHandler, - GetPromptHandler = getPromptHandler, - PromptCollection = prompts, - ListChanged = true, - } - }; + listChanged = true; } - else - { - ServerCapabilities = options.Capabilities; - - if (promptsCapability is null) - { - // No prompts, and no prompts capability was declared, so nothing to do. - return; - } - // Make sure the handlers are provided if the capability is enabled. - if (listPromptsHandler is null || getPromptHandler is null) - { - throw new InvalidOperationException( - $"{nameof(ServerCapabilities)}.{nameof(ServerCapabilities.Prompts)} was enabled, " + - $"but {nameof(PromptsCapability.ListPromptsHandler)} or {nameof(PromptsCapability.GetPromptHandler)} was not specified."); - } - } + ServerCapabilities.Prompts.ListPromptsHandler = listPromptsHandler; + ServerCapabilities.Prompts.GetPromptHandler = getPromptHandler; + ServerCapabilities.Prompts.PromptCollection = prompts; + ServerCapabilities.Prompts.ListChanged = listChanged; SetHandler( RequestMethods.PromptsList, @@ -371,25 +395,23 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals McpJsonUtilities.JsonContext.Default.GetPromptResult); } - private void SetToolsHandler(McpServerOptions options) + private void ConfigureTools(McpServerOptions options) { - ToolsCapability? toolsCapability = options.Capabilities?.Tools; - var listToolsHandler = toolsCapability?.ListToolsHandler; - var callToolHandler = toolsCapability?.CallToolHandler; - var tools = toolsCapability?.ToolCollection; - - if (listToolsHandler is null != callToolHandler is null) + if (options.Capabilities?.Tools is not { } toolsCapability) { - throw new InvalidOperationException( - $"{nameof(ToolsCapability)}.{nameof(ToolsCapability.ListToolsHandler)} or " + - $"{nameof(ToolsCapability)}.{nameof(ToolsCapability.CallToolHandler)} was specified without the other. " + - $"Both or neither must be provided."); + return; } - // Handle tools provided via DI. + ServerCapabilities.Tools = new(); + + var listToolsHandler = toolsCapability.ListToolsHandler ?? (static async (_, __) => new ListToolsResult()); + var callToolHandler = toolsCapability.CallToolHandler ?? (static async (request, _) => throw new McpException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); + var tools = toolsCapability.ToolCollection; + var listChanged = toolsCapability.ListChanged; + + // Handle tools provided via DI by augmenting the handlers to incorporate them. if (tools is { IsEmpty: false }) { - // Synthesize the handlers, making sure a ToolsCapability is specified. var originalListToolsHandler = listToolsHandler; listToolsHandler = async (request, cancellationToken) => { @@ -408,53 +430,22 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) var originalCallToolHandler = callToolHandler; callToolHandler = (request, cancellationToken) => { - if (request.Params is null || - !tools.TryGetPrimitive(request.Params.Name, out var tool)) + if (request.Params is not null && + tools.TryGetPrimitive(request.Params.Name, out var tool)) { - if (originalCallToolHandler is not null) - { - return originalCallToolHandler(request, cancellationToken); - } - - throw new McpException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams); + return tool.InvokeAsync(request, cancellationToken); } - return tool.InvokeAsync(request, cancellationToken); + return originalCallToolHandler(request, cancellationToken); }; - ServerCapabilities = new() - { - Experimental = options.Capabilities?.Experimental, - Logging = options.Capabilities?.Logging, - Prompts = options.Capabilities?.Prompts, - Resources = options.Capabilities?.Resources, - Tools = new() - { - ListToolsHandler = listToolsHandler, - CallToolHandler = callToolHandler, - ToolCollection = tools, - ListChanged = true, - } - }; + listChanged = true; } - else - { - ServerCapabilities = options.Capabilities; - if (toolsCapability is null) - { - // No tools, and no tools capability was declared, so nothing to do. - return; - } - - // Make sure the handlers are provided if the capability is enabled. - if (listToolsHandler is null || callToolHandler is null) - { - throw new InvalidOperationException( - $"{nameof(ServerCapabilities)}.{nameof(ServerCapabilities.Tools)} was enabled, " + - $"but {nameof(ToolsCapability.ListToolsHandler)} or {nameof(ToolsCapability.CallToolHandler)} was not specified."); - } - } + ServerCapabilities.Tools.ListToolsHandler = listToolsHandler; + ServerCapabilities.Tools.CallToolHandler = callToolHandler; + ServerCapabilities.Tools.ToolCollection = tools; + ServerCapabilities.Tools.ListChanged = listChanged; SetHandler( RequestMethods.ToolsList, @@ -469,12 +460,14 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.CallToolResponse); } - private void SetSetLoggingLevelHandler(McpServerOptions options) + private void ConfigureLogging(McpServerOptions options) { - // We don't require that the handler be provided, as we always store the provided - // log level to the server. + // We don't require that the handler be provided, as we always store the provided log level to the server. var setLoggingLevelHandler = options.Capabilities?.Logging?.SetLoggingLevelHandler; + ServerCapabilities.Logging = new(); + ServerCapabilities.Logging.SetLoggingLevelHandler = setLoggingLevelHandler; + RequestHandlers.Set( RequestMethods.LoggingSetLevel, (request, destinationTransport, cancellationToken) => diff --git a/src/ModelContextProtocol/Server/McpServerExtensions.cs b/src/ModelContextProtocol/Server/McpServerExtensions.cs index 1b4643f4..9450517c 100644 --- a/src/ModelContextProtocol/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol/Server/McpServerExtensions.cs @@ -29,7 +29,7 @@ public static class McpServerExtensions /// It allows detailed control over sampling parameters including messages, system prompt, temperature, /// and token limits. /// - public static Task RequestSamplingAsync( + public static ValueTask RequestSamplingAsync( this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken) { Throw.IfNull(server); @@ -197,7 +197,7 @@ public static ILoggerProvider AsClientLoggerProvider(this IMcpServer server) /// navigated and accessed by the server. These resources might include file systems, databases, /// or other structured data sources that the client makes available through the protocol. /// - public static Task RequestRootsAsync( + public static ValueTask RequestRootsAsync( this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken) { Throw.IfNull(server); diff --git a/src/ModelContextProtocol/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol/Server/McpServerPrimitiveCollection.cs index 0c90524f..871f55ac 100644 --- a/src/ModelContextProtocol/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol/Server/McpServerPrimitiveCollection.cs @@ -65,7 +65,7 @@ public void Add(T primitive) { if (!TryAdd(primitive)) { - throw new ArgumentException($"A primitive with the same name '{primitive.Name}' already exists in the collection.", nameof(primitive)); + throw new ArgumentException($"A primitive with the same name '{primitive.Id}' already exists in the collection.", nameof(primitive)); } } @@ -77,7 +77,7 @@ public virtual bool TryAdd(T primitive) { Throw.IfNull(primitive); - bool added = _primitives.TryAdd(primitive.Name, primitive); + bool added = _primitives.TryAdd(primitive.Id, primitive); if (added) { RaiseChanged(); @@ -96,7 +96,7 @@ public virtual bool Remove(T primitive) { Throw.IfNull(primitive); - bool removed = ((ICollection>)_primitives).Remove(new(primitive.Name, primitive)); + bool removed = ((ICollection>)_primitives).Remove(new(primitive.Id, primitive)); if (removed) { RaiseChanged(); @@ -125,7 +125,7 @@ public virtual bool TryGetPrimitive(string name, [NotNullWhen(true)] out T? prim public virtual bool Contains(T primitive) { Throw.IfNull(primitive); - return ((ICollection>)_primitives).Contains(new(primitive.Name, primitive)); + return ((ICollection>)_primitives).Contains(new(primitive.Id, primitive)); } /// Gets the names of all of the primitives in the collection. diff --git a/src/ModelContextProtocol/Server/McpServerPrompt.cs b/src/ModelContextProtocol/Server/McpServerPrompt.cs index f1c7187f..695a5cd2 100644 --- a/src/ModelContextProtocol/Server/McpServerPrompt.cs +++ b/src/ModelContextProtocol/Server/McpServerPrompt.cs @@ -217,5 +217,5 @@ public static McpServerPrompt Create( public override string ToString() => ProtocolPrompt.Name; /// - string IMcpServerPrimitive.Name => ProtocolPrompt.Name; + string IMcpServerPrimitive.Id => ProtocolPrompt.Name; } diff --git a/src/ModelContextProtocol/Server/McpServerPromptAttribute.cs b/src/ModelContextProtocol/Server/McpServerPromptAttribute.cs index fdf41db5..ed66a41e 100644 --- a/src/ModelContextProtocol/Server/McpServerPromptAttribute.cs +++ b/src/ModelContextProtocol/Server/McpServerPromptAttribute.cs @@ -97,7 +97,7 @@ namespace ModelContextProtocol.Server; /// Converted to a list of instances derived from the with . /// /// -/// of +/// of /// Converted to a list of instances derived from all of the instances with . /// /// diff --git a/src/ModelContextProtocol/Server/McpServerResource.cs b/src/ModelContextProtocol/Server/McpServerResource.cs new file mode 100644 index 00000000..6f928c6a --- /dev/null +++ b/src/ModelContextProtocol/Server/McpServerResource.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol.Messages; +using ModelContextProtocol.Protocol.Types; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace ModelContextProtocol.Server; + +/// +/// Represents an invocable resource used by Model Context Protocol clients and servers. +/// +/// +/// +/// is an abstract base class that represents an MCP resource for use in the server (as opposed +/// to or , which provide the protocol representations of a resource). Instances of +/// can be added into a to be picked up automatically when +/// is used to create an , or added into a . +/// +/// +/// Most commonly, instances are created using the static methods. +/// These methods enable creating an for a method, specified via a or +/// , and are what are used implicitly by and +/// . The methods +/// create instances capable of working with a large variety of .NET method signatures, automatically handling +/// how parameters are marshaled into the method from the URI received from the MCP client, and how the return value is marshaled back +/// into the that's then serialized and sent back to the client. +/// +/// +/// is used to represent both direct resources (e.g. "resource://example") and templated +/// resources (e.g. "resource://example/{id}"). +/// +/// +/// Read resource requests do not contain separate arguments, only a URI. However, for templated resources, portions of that URI may be considered +/// as arguments and may be bound to parameters. Further, resource methods may accept parameters that will be bound to arguments based on their type. +/// +/// +/// +/// parameters are automatically bound to a provided by the +/// and that respects any s sent by the client for this operation's +/// . +/// +/// +/// +/// +/// parameters are bound from the for this request. +/// +/// +/// +/// +/// parameters are bound directly to the instance associated +/// with this request's . Such parameters may be used to understand +/// what server is being used to process the request, and to interact with the client issuing the request to that server. +/// +/// +/// +/// +/// parameters accepting values +/// are bound to an instance manufactured to forward progress notifications +/// from the resource to the client. If the client included a in their request, progress reports issued +/// to this instance will propagate to the client as notifications with +/// that token. If the client did not include a , the instance will ignore any progress reports issued to it. +/// +/// +/// +/// +/// When the is constructed, it may be passed an via +/// . Any parameter that can be satisfied by that +/// according to will be resolved from the provided to the +/// resource invocation rather than from the argument collection. +/// +/// +/// +/// +/// Any parameter attributed with will similarly be resolved from the +/// provided to the resource invocation rather than from the argument collection. +/// +/// +/// +/// +/// All other parameters are bound from the data in the URI. +/// +/// +/// +/// +/// +/// Return values from a method are used to create the that is sent back to the client: +/// +/// +/// +/// +/// Wrapped in a list containing the single . +/// +/// +/// +/// Converted to a list containing a single . +/// +/// +/// +/// Converted to a list containing a single . +/// +/// +/// +/// Converted to a list containing a single . +/// +/// +/// of +/// Returned directly as a list of . +/// +/// +/// of +/// Converted to a list containing a for each and a for each . +/// +/// +/// of +/// Converted to a list containing a , one for each . +/// +/// +/// +/// Other returned types will result in an being thrown. +/// +/// +public abstract class McpServerResource : IMcpServerPrimitive +{ + /// Initializes a new instance of the class. + protected McpServerResource() + { + } + + /// Gets whether this resource is a URI template with parameters as opposed to a direct resource. + public bool IsTemplated => ProtocolResourceTemplate.UriTemplate.Contains('{'); + + /// Gets the protocol type for this instance. + /// + /// + /// The property represents the underlying resource template definition as defined in the + /// Model Context Protocol specification. It contains metadata like the resource templates's URI template, name, and description. + /// + /// + /// Every valid resource URI is a valid resource URI template, and thus this property always returns an instance. + /// In contrast, the property may return if the resource template + /// contains a parameter, in which case the resource template URI is not a valid resource URI. + /// + /// + public abstract ResourceTemplate ProtocolResourceTemplate { get; } + + /// Gets the protocol type for this instance. + /// + /// The ProtocolResourceTemplate property represents the underlying resource template definition as defined in the + /// Model Context Protocol specification. It contains metadata like the resource templates's URI template, name, and description. + /// + public virtual Resource? ProtocolResource => ProtocolResourceTemplate.AsResource(); + + /// + /// Gets the resource, rendering it with the provided request parameters and returning the resource result. + /// + /// + /// The request context containing information about the resource invocation, including any arguments + /// passed to the resource. This object provides access to both the request parameters and the server context. + /// + /// + /// The to monitor for cancellation requests. The default is . + /// + /// + /// A representing the asynchronous operation, containing a with + /// the resource content and messages. If and only if this doesn't match the , + /// the method returns . + /// + /// is . + /// The resource implementation returned or an unsupported result type. + public abstract ValueTask ReadAsync( + RequestContext request, + CancellationToken cancellationToken = default); + + /// + /// Creates an instance for a method, specified via a instance. + /// + /// The method to be represented via the created . + /// Optional options used in the creation of the to control its behavior. + /// The created for invoking . + /// is . + public static McpServerResource Create( + Delegate method, + McpServerResourceCreateOptions? options = null) => + AIFunctionMcpServerResource.Create(method, options); + + /// + /// Creates an instance for a method, specified via a instance. + /// + /// The method to be represented via the created . + /// The instance if is an instance method; otherwise, . + /// Optional options used in the creation of the to control its behavior. + /// The created for invoking . + /// is . + /// is an instance method but is . + public static McpServerResource Create( + MethodInfo method, + object? target = null, + McpServerResourceCreateOptions? options = null) => + AIFunctionMcpServerResource.Create(method, target, options); + + /// + /// Creates an instance for a method, specified via an for + /// and instance method, along with a representing the type of the target object to + /// instantiate each time the method is invoked. + /// + /// The instance method to be represented via the created . + /// + /// The to construct an instance of on which to invoke when + /// the resulting is invoked. If services are provided, + /// ActivatorUtilities.CreateInstance will be used to construct the instance using those services; otherwise, + /// is used, utilizing the type's public parameterless constructor. + /// If an instance can't be constructed, an exception is thrown during the function's invocation. + /// + /// Optional options used in the creation of the to control its behavior. + /// The created for invoking . + /// is . + public static McpServerResource Create( + MethodInfo method, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType, + McpServerResourceCreateOptions? options = null) => + AIFunctionMcpServerResource.Create(method, targetType, options); + + /// Creates an that wraps the specified . + /// The function to wrap. + /// Optional options used in the creation of the to control its behavior. + /// is . + /// + /// Unlike the other overloads of Create, the created by + /// does not provide all of the special parameter handling for MCP-specific concepts, like . + /// + public static McpServerResource Create( + AIFunction function, + McpServerResourceCreateOptions? options = null) => + AIFunctionMcpServerResource.Create(function, options); + + /// + public override string ToString() => ProtocolResourceTemplate.UriTemplate; + + /// + string IMcpServerPrimitive.Id => ProtocolResourceTemplate.UriTemplate; +} diff --git a/src/ModelContextProtocol/Server/McpServerResourceAttribute.cs b/src/ModelContextProtocol/Server/McpServerResourceAttribute.cs new file mode 100644 index 00000000..c5989233 --- /dev/null +++ b/src/ModelContextProtocol/Server/McpServerResourceAttribute.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol.Messages; +using ModelContextProtocol.Protocol.Types; + +namespace ModelContextProtocol.Server; + +/// +/// Used to indicate that a method or property should be considered an . +/// +/// +/// +/// This attribute is applied to methods or properties that should be exposed as resources in the Model Context Protocol. When a class +/// containing methods marked with this attribute is registered with , +/// these methods or properties become available as resources that can be called by MCP clients. +/// +/// +/// When methods are provided directly to , the attribute is not required. +/// +/// +/// Read resource requests do not contain separate arguments, only a URI. However, for templated resources, portions of that URI may be considered +/// as arguments and may be bound to parameters. Further, resource methods may accept parameters that will be bound to arguments based on their type. +/// +/// +/// +/// parameters are automatically bound to a provided by the +/// and that respects any s sent by the client for this operation's +/// . +/// +/// +/// +/// +/// parameters are bound from the for this request. +/// +/// +/// +/// +/// parameters are bound directly to the instance associated +/// with this request's . Such parameters may be used to understand +/// what server is being used to process the request, and to interact with the client issuing the request to that server. +/// +/// +/// +/// +/// parameters accepting values +/// are bound to an instance manufactured to forward progress notifications +/// from the resource to the client. If the client included a in their request, progress reports issued +/// to this instance will propagate to the client as notifications with +/// that token. If the client did not include a , the instance will ignore any progress reports issued to it. +/// +/// +/// +/// +/// When the is constructed, it may be passed an via +/// . Any parameter that can be satisfied by that +/// according to will be resolved from the provided to the +/// resource invocation rather than from the argument collection. +/// +/// +/// +/// +/// Any parameter attributed with will similarly be resolved from the +/// provided to the resource invocation rather than from the argument collection. +/// +/// +/// +/// +/// All other parameters are bound from the data in the URI. +/// +/// +/// +/// +/// +/// Return values from a method are used to create the that is sent back to the client: +/// +/// +/// +/// +/// Wrapped in a list containing the single . +/// +/// +/// +/// Converted to a list containing a single . +/// +/// +/// +/// Converted to a list containing a single . +/// +/// +/// +/// Converted to a list containing a single . +/// +/// +/// of +/// Returned directly as a list of . +/// +/// +/// of +/// Converted to a list containing a for each and a for each . +/// +/// +/// of +/// Converted to a list containing a , one for each . +/// +/// +/// +/// Other returned types will result in an being thrown. +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class McpServerResourceAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public McpServerResourceAttribute() + { + } + + /// Gets or sets the URI template of the resource. + /// + /// If , a URI will be derived from and the method's parameter names. + /// This template may, but doesn't have to, include parameters; if it does, this + /// will be considered a "resource template", and if it doesn't, it will be considered a "direct resource". + /// The former will be listed with requests and the latter + /// with requests. + /// + public string? UriTemplate { get; set; } + + /// Gets or sets the name of the resource. + /// If , the method name will be used. + public string? Name { get; set; } + + /// Gets or sets the MIME (media) type of the resource. + public string? MimeType { get; set; } +} diff --git a/src/ModelContextProtocol/Server/McpServerResourceCreateOptions.cs b/src/ModelContextProtocol/Server/McpServerResourceCreateOptions.cs new file mode 100644 index 00000000..6ed0d0f2 --- /dev/null +++ b/src/ModelContextProtocol/Server/McpServerResourceCreateOptions.cs @@ -0,0 +1,75 @@ +using System.ComponentModel; + +namespace ModelContextProtocol.Server; + +/// +/// Provides options for controlling the creation of an . +/// +/// +/// +/// These options allow for customizing the behavior and metadata of resources created with +/// . They provide control over naming, description, +/// and dependency injection integration. +/// +/// +/// When creating resources programmatically rather than using attributes, these options +/// provide the same level of configuration flexibility. +/// +/// +public sealed class McpServerResourceCreateOptions +{ + /// + /// Gets or sets optional services used in the construction of the . + /// + /// + /// These services will be used to determine which parameters should be satisifed from dependency injection. As such, + /// what services are satisfied via this provider should match what's satisfied via the provider passed in at invocation time. + /// + public IServiceProvider? Services { get; set; } + + /// + /// Gets or sets the URI template of the . + /// + /// + /// If , but an is applied to the member, + /// the from the attribute will be used. If that's not present, + /// a URI template will be inferred from the member's signature. + /// + public string? UriTemplate { get; set; } + + /// + /// Gets or sets the name to use for the . + /// + /// + /// If , but an is applied to the member, + /// the name from the attribute will be used. If that's not present, a name based on the members's name will be used. + /// + public string? Name { get; set; } + + /// + /// Gets or set the description to use for the . + /// + /// + /// If , but a is applied to the member, + /// the description from that attribute will be used. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the MIME (media) type of the . + /// + public string? MimeType { get; set; } + + /// + /// Creates a shallow clone of the current instance. + /// + internal McpServerResourceCreateOptions Clone() => + new McpServerResourceCreateOptions + { + Services = Services, + UriTemplate = UriTemplate, + Name = Name, + Description = Description, + MimeType = MimeType, + }; +} diff --git a/src/ModelContextProtocol/Server/McpServerResourceTypeAttribute.cs b/src/ModelContextProtocol/Server/McpServerResourceTypeAttribute.cs new file mode 100644 index 00000000..b73bd081 --- /dev/null +++ b/src/ModelContextProtocol/Server/McpServerResourceTypeAttribute.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace ModelContextProtocol.Server; + +/// +/// Used to attribute a type containing members that should be exposed as s. +/// +/// +/// +/// This attribute is used to mark a class containing members that should be automatically +/// discovered and registered as s. When combined with discovery methods like +/// , it enables automatic registration +/// of resources without explicitly listing each resource class. The attribute is not necessary when a reference +/// to the type is provided directly to a method like . +/// +/// +/// Within a class marked with this attribute, individual members that should be exposed as +/// resources must be marked with the . +/// +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class McpServerResourceTypeAttribute : Attribute; diff --git a/src/ModelContextProtocol/Server/McpServerTool.cs b/src/ModelContextProtocol/Server/McpServerTool.cs index f169c64c..ee88d18e 100644 --- a/src/ModelContextProtocol/Server/McpServerTool.cs +++ b/src/ModelContextProtocol/Server/McpServerTool.cs @@ -220,5 +220,5 @@ public static McpServerTool Create( public override string ToString() => ProtocolTool.Name; /// - string IMcpServerPrimitive.Name => ProtocolTool.Name; + string IMcpServerPrimitive.Id => ProtocolTool.Name; } diff --git a/src/ModelContextProtocol/UriTemplate.cs b/src/ModelContextProtocol/UriTemplate.cs new file mode 100644 index 00000000..b224706e --- /dev/null +++ b/src/ModelContextProtocol/UriTemplate.cs @@ -0,0 +1,457 @@ +using ModelContextProtocol.Utils; +#if NET +using System.Buffers; +#endif +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace ModelContextProtocol; + +/// Provides basic support for parsing and formatting URI templates. +/// +/// This implementation should correctly handle valid URI templates, but it has undefined output for invalid templates, +/// e.g. it may treat portions of invalid templates as literals rather than throwing. +/// +internal static partial class UriTemplate +{ + /// Regex pattern for finding URI template expressions and parsing out the operator and varname. + private const string UriTemplateExpressionPattern = """ + { # opening brace + (?[+#./;?&]?) # optional operator + (? + (?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2}) # varchar: letter, digit, underscore, or pct-encoded + (?:\.?(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2}))* # optionally dot-separated subsequent varchars + ) + (?: :[1-9][0-9]{0,3} )? # optional prefix modifier (1–4 digits) + \*? # optional explode + (?:, # comma separator, followed by the same as above + (? + (?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2}) + (?:\.?(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2}))* + ) + (?: :[1-9][0-9]{0,3} )? + \*? + )* # zero or more additional vars + } # closing brace + """; + + /// Gets a regex for finding URI template expressions and parsing out the operator and varname. + /// + /// This regex is for parsing a static URI template. + /// It is not for parsing a URI according to a template. + /// +#if NET + [GeneratedRegex(UriTemplateExpressionPattern, RegexOptions.IgnorePatternWhitespace)] + private static partial Regex UriTemplateExpression(); +#else + private static Regex UriTemplateExpression() => s_uriTemplateExpression; + private static readonly Regex s_uriTemplateExpression = new(UriTemplateExpressionPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); +#endif + +#if NET + /// SearchValues for characters that needn't be escaped when allowing reserved characters. + private static readonly SearchValues s_appendWhenAllowReserved = SearchValues.Create( + "abcdefghijklmnopqrstuvwxyz" + // ASCII lowercase letters + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + // ASCII uppercase letters + "0123456789" + // ASCII digits + "-._~" + // unreserved characters + ":/?#[]@!$&'()*+,;="); // reserved characters +#endif + + /// Create a for matching a URI against a URI template. + /// The template against which to match. + /// A regex pattern that can be used to match the specified URI template. + public static Regex CreateParser(string uriTemplate) + { + DefaultInterpolatedStringHandler pattern = new(0, 0, CultureInfo.InvariantCulture, stackalloc char[256]); + pattern.AppendFormatted('^'); + + int lastIndex = 0; + for (Match m = UriTemplateExpression().Match(uriTemplate); m.Success; m = m.NextMatch()) + { + pattern.AppendFormatted(Regex.Escape(uriTemplate[lastIndex..m.Index])); + lastIndex = m.Index + m.Length; + + var captures = m.Groups["varname"].Captures; + List paramNames = new(captures.Count); + foreach (Capture c in captures) + { + paramNames.Add(c.Value); + } + + switch (m.Groups["operator"].Value) + { + case "#": AppendExpression(ref pattern, paramNames, '#', "[^,]+"); break; + case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?]+"); break; + default: AppendExpression(ref pattern, paramNames, null, "[^/?&]+"); break; + + case "?": AppendQueryExpression(ref pattern, paramNames, '?'); break; + case "&": AppendQueryExpression(ref pattern, paramNames, '&'); break; + } + } + + pattern.AppendFormatted(Regex.Escape(uriTemplate.Substring(lastIndex))); + pattern.AppendFormatted('$'); + + return new Regex( + pattern.ToStringAndClear(), + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | +#if NET + RegexOptions.NonBacktracking); +#else + RegexOptions.Compiled, TimeSpan.FromSeconds(10)); +#endif + + // Appends a regex fragment to `pattern` that matches an optional query string starting + // with the given `prefix` (? or &), and up to one occurrence of each name in + // `paramNames`. Each parameter is made optional and captured by a named group + // of the form “paramName=value”. + static void AppendQueryExpression(ref DefaultInterpolatedStringHandler pattern, List paramNames, char prefix) + { + Debug.Assert(prefix is '?' or '&'); + + pattern.AppendFormatted("(?:\\"); + pattern.AppendFormatted(prefix); + + if (paramNames.Count > 0) + { + AppendParameter(ref pattern, paramNames[0]); + for (int i = 1; i < paramNames.Count; i++) + { + pattern.AppendFormatted("\\&?"); + AppendParameter(ref pattern, paramNames[i]); + } + + static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName) + { + paramName = Regex.Escape(paramName); + pattern.AppendFormatted("(?:"); + pattern.AppendFormatted(paramName); + pattern.AppendFormatted("=(?<"); + pattern.AppendFormatted(paramName); + pattern.AppendFormatted(">[^/?&]+))?"); + } + } + + pattern.AppendFormatted(")?"); + } + + // Chooses a regex character‐class (`valueChars`) based on the initial `prefix` to define which + // characters make up a parameter value. Then, for each name in `paramNames`, it optionally + // appends the escaped `prefix` (only on the first parameter, then switches to ','), and + // adds an optional named capture group `(?valueChars)` to match and capture that value. + static void AppendExpression(ref DefaultInterpolatedStringHandler pattern, List paramNames, char? prefix, string valueChars) + { + Debug.Assert(prefix is '#' or '/' or null); + + if (paramNames.Count > 0) + { + if (prefix is not null) + { + pattern.AppendFormatted('\\'); + pattern.AppendFormatted(prefix); + pattern.AppendFormatted('?'); + } + + AppendParameter(ref pattern, paramNames[0], valueChars); + for (int i = 1; i < paramNames.Count; i++) + { + pattern.AppendFormatted("\\,?"); + AppendParameter(ref pattern, paramNames[i], valueChars); + } + + static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName, string valueChars) + { + pattern.AppendFormatted("(?<"); + pattern.AppendFormatted(Regex.Escape(paramName)); + pattern.AppendFormatted('>'); + pattern.AppendFormatted(valueChars); + pattern.AppendFormatted(")?"); + } + } + } + } + + /// + /// Expand a URI template using the given variable values. + /// + public static string FormatUri(string uriTemplate, IReadOnlyDictionary arguments) + { + Throw.IfNull(uriTemplate); + + ReadOnlySpan uriTemplateSpan = uriTemplate.AsSpan(); + DefaultInterpolatedStringHandler builder = new(0, 0, CultureInfo.InvariantCulture, stackalloc char[256]); + while (!uriTemplateSpan.IsEmpty) + { + // Find the next expression. + int openBracePos = uriTemplateSpan.IndexOf('{'); + if (openBracePos < 0) + { + if (uriTemplate.Length == uriTemplateSpan.Length) + { + return uriTemplate; + } + + builder.AppendFormatted(uriTemplateSpan); + break; + } + + // Append as a literal everything before the next expression. + builder.AppendFormatted(uriTemplateSpan.Slice(0, openBracePos)); + uriTemplateSpan = uriTemplateSpan.Slice(openBracePos + 1); + + int closeBracePos = uriTemplateSpan.IndexOf('}'); + if (closeBracePos < 0) + { + throw new FormatException($"Unmatched '{{' in URI template '{uriTemplate}'"); + } + + ReadOnlySpan expression = uriTemplateSpan.Slice(0, closeBracePos); + uriTemplateSpan = uriTemplateSpan.Slice(closeBracePos + 1); + if (expression.IsEmpty) + { + continue; + } + + // The start of the expression may be a modifier; if it is, slice it off the expression. + char modifier = expression[0]; + (string Prefix, string Separator, bool Named, bool IncludeNameIfEmpty, bool IncludeSeparatorIfEmpty, bool AllowReserved, bool PrefixEmptyExpansions, int ExpressionSlice) modifierBehavior = modifier switch + { + '+' => (string.Empty, ",", false, false, true, true, false, 1), + '#' => ("#", ",", false, false, true, true, true, 1), + '.' => (".", ".", false, false, true, false, true, 1), + '/' => ("/", "/", false, false, true, false, false, 1), + ';' => (";", ";", true, true, false, false, false, 1), + '?' => ("?", "&", true, true, true, false, false, 1), + '&' => ("&", "&", true, true, true, false, false, 1), + _ => (string.Empty, ",", false, false, true, false, false, 0), + }; + expression = expression.Slice(modifierBehavior.ExpressionSlice); + + List expansions = []; + + // Process each varspec in the comma-delimited list in the expression (if it doesn't have any + // commas, it will be the whole expression). + while (!expression.IsEmpty) + { + // Find the next name. + int commaPos = expression.IndexOf(','); + ReadOnlySpan name; + if (commaPos < 0) + { + name = expression; + expression = ReadOnlySpan.Empty; + } + else + { + name = expression.Slice(0, commaPos); + expression = expression.Slice(commaPos + 1); + } + + bool explode = false; + int prefixLength = -1; + + // If the name ends with a *, it means we should explode the value into separate + // name=value pairs. If it has a colon, it means we should only take the first N characters + // of the value. If it has both, the * takes precedence and we ignore the colon. + if (!name.IsEmpty && name[name.Length - 1] == '*') + { + explode = true; + name = name.Slice(0, name.Length - 1); + } + else if (name.IndexOf(':') >= 0) + { + int colonPos = name.IndexOf(':'); + if (colonPos < 0) + { + throw new FormatException($"Invalid varspec '{name.ToString()}'"); + } + + if (!int.TryParse(name.Slice(colonPos + 1) +#if !NET + .ToString() +#endif + , out prefixLength)) + { + throw new FormatException($"Invalid prefix length in varspec '{name.ToString()}'"); + } + + name = name.Slice(0, colonPos); + } + + // Look up the value for this name. If it doesn't exist, skip it. + string nameString = name.ToString(); + if (!arguments.TryGetValue(nameString, out var value) || value is null) + { + continue; + } + + if (value is IEnumerable list) + { + var items = list.Select(i => Encode(i, modifierBehavior.AllowReserved)); + if (explode) + { + if (modifierBehavior.Named) + { + foreach (var item in items) + { + expansions.Add($"{nameString}={item}"); + } + } + else + { + foreach (var item in items) + { + expansions.Add(item); + } + } + } + else + { + var joined = string.Join(",", items); + expansions.Add(joined.Length > 0 && modifierBehavior.Named ? + $"{nameString}={joined}" : + joined); + } + } + else if (value is IReadOnlyDictionary assoc) + { + var pairs = assoc.Select(kvp => ( + Encode(kvp.Key, modifierBehavior.AllowReserved), + Encode(kvp.Value, modifierBehavior.AllowReserved) + )); + + if (explode) + { + foreach (var (k, v) in pairs) + { + expansions.Add($"{k}={v}"); + } + } + else + { + var joined = string.Join(",", pairs.Select(p => $"{p.Item1},{p.Item2}")); + if (joined.Length > 0) + { + expansions.Add(modifierBehavior.Named ? $"{nameString}={joined}" : joined); + } + } + } + else + { + string s = + value as string ?? + (value is IFormattable f ? f.ToString(null, CultureInfo.InvariantCulture) : value.ToString()) ?? + string.Empty; + + s = Encode((uint)prefixLength < s.Length ? s.Substring(0, prefixLength) : s, modifierBehavior.AllowReserved); + if (!modifierBehavior.Named) + { + expansions.Add(s); + } + else if (s.Length != 0 || modifierBehavior.IncludeNameIfEmpty) + { + expansions.Add( + s.Length != 0 ? $"{nameString}={s}" : + modifierBehavior.IncludeSeparatorIfEmpty ? $"{nameString}=" : + nameString); + } + } + } + + if (expansions.Count > 0 && + (modifierBehavior.PrefixEmptyExpansions || !expansions.All(string.IsNullOrEmpty))) + { + builder.AppendLiteral(modifierBehavior.Prefix); + AppendJoin(ref builder, modifierBehavior.Separator, expansions); + } + } + + return builder.ToStringAndClear(); + } + + private static void AppendJoin(ref DefaultInterpolatedStringHandler builder, string separator, IList values) + { + int count = values.Count; + if (count > 0) + { + builder.AppendLiteral(values[0]); + for (int i = 1; i < count; i++) + { + builder.AppendLiteral(separator); + builder.AppendLiteral(values[i]); + } + } + } + + private static string Encode(string value, bool allowReserved) + { + if (!allowReserved) + { + return Uri.EscapeDataString(value); + } + + DefaultInterpolatedStringHandler builder = new(0, 0, CultureInfo.InvariantCulture, stackalloc char[256]); + int i = 0; +#if NET + i = value.AsSpan().IndexOfAnyExcept(s_appendWhenAllowReserved); + if (i < 0) + { + return value; + } + + builder.AppendFormatted(value.AsSpan(0, i)); +#endif + + for (; i < value.Length; ++i) + { + char c = value[i]; + if (((uint)((c | 0x20) - 'a') <= 'z' - 'a') || + ((uint)(c - '0') <= '9' - '0') || + "-._~:/?#[]@!$&'()*+,;=".Contains(c)) + { + builder.AppendFormatted(c); + } + else if (c == '%' && i < value.Length - 2 && Uri.IsHexDigit(value[i + 1]) && Uri.IsHexDigit(value[i + 2])) + { + builder.AppendFormatted(value.AsSpan(i, 3)); + i += 2; + } + else + { + AppendHex(ref builder, c); + } + } + + return builder.ToStringAndClear(); + + static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) + { + ReadOnlySpan hexDigits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; + + if (c <= 0x7F) + { + builder.AppendFormatted('%'); + builder.AppendFormatted(hexDigits[c >> 4]); + builder.AppendFormatted(hexDigits[c & 0xF]); + } + else + { +#if NET + Span utf8 = stackalloc byte[Encoding.UTF8.GetMaxByteCount(1)]; + foreach (byte b in utf8.Slice(0, new Rune(c).EncodeToUtf8(utf8))) +#else + foreach (byte b in Encoding.UTF8.GetBytes([c])) +#endif + { + builder.AppendFormatted('%'); + builder.AppendFormatted(hexDigits[b >> 4]); + builder.AppendFormatted(hexDigits[b & 0xF]); + } + } + } + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index b759ba97..625b558f 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -123,6 +123,61 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(UnsubscribeRequestParams))] [JsonSerializable(typeof(IReadOnlyDictionary))] + // Primitive types for use in consuming AIFunctions + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(byte))] + [JsonSerializable(typeof(byte?))] + [JsonSerializable(typeof(sbyte))] + [JsonSerializable(typeof(sbyte?))] + [JsonSerializable(typeof(ushort))] + [JsonSerializable(typeof(ushort?))] + [JsonSerializable(typeof(short))] + [JsonSerializable(typeof(short?))] + [JsonSerializable(typeof(uint))] + [JsonSerializable(typeof(uint?))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(ulong))] + [JsonSerializable(typeof(ulong?))] + [JsonSerializable(typeof(long))] + [JsonSerializable(typeof(long?))] + [JsonSerializable(typeof(nuint))] + [JsonSerializable(typeof(nuint?))] + [JsonSerializable(typeof(nint))] + [JsonSerializable(typeof(nint?))] + [JsonSerializable(typeof(bool))] + [JsonSerializable(typeof(bool?))] + [JsonSerializable(typeof(char))] + [JsonSerializable(typeof(char?))] + [JsonSerializable(typeof(float))] + [JsonSerializable(typeof(float?))] + [JsonSerializable(typeof(double))] + [JsonSerializable(typeof(double?))] + [JsonSerializable(typeof(decimal))] + [JsonSerializable(typeof(decimal?))] + [JsonSerializable(typeof(Guid))] + [JsonSerializable(typeof(Guid?))] + [JsonSerializable(typeof(Uri))] + [JsonSerializable(typeof(Version))] + [JsonSerializable(typeof(TimeSpan))] + [JsonSerializable(typeof(TimeSpan?))] + [JsonSerializable(typeof(DateTime))] + [JsonSerializable(typeof(DateTime?))] + [JsonSerializable(typeof(DateTimeOffset))] + [JsonSerializable(typeof(DateTimeOffset?))] +#if NET + [JsonSerializable(typeof(DateOnly))] + [JsonSerializable(typeof(DateOnly?))] + [JsonSerializable(typeof(TimeOnly))] + [JsonSerializable(typeof(TimeOnly?))] + [JsonSerializable(typeof(Half))] + [JsonSerializable(typeof(Half?))] + [JsonSerializable(typeof(Int128))] + [JsonSerializable(typeof(Int128?))] + [JsonSerializable(typeof(UInt128))] + [JsonSerializable(typeof(UInt128?))] +#endif + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 57a6c6ad..cf62c812 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -99,7 +99,7 @@ public async Task ListResources_Sse_TestServer() // act await using var client = await GetClientAsync(); - IList allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + IList allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); // The everything server provides 100 test resources Assert.Equal(100, allResources.Count); @@ -200,8 +200,7 @@ public async Task GetPrompt_Sse_NonExistent_ThrowsException() // act await using var client = await GetClientAsync(); - await Assert.ThrowsAsync(() => - client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerIntegrationTests.cs index 9d304892..3abb1aa3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerIntegrationTests.cs @@ -1,5 +1,4 @@ using ModelContextProtocol.Protocol.Transport; -using System.Net; using System.Text; namespace ModelContextProtocol.AspNetCore.Tests; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs index 054d5227..adef0024 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs @@ -286,7 +286,7 @@ public async Task SendRequestAsync_HonorsJsonSerializerOptions() JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; await using IMcpClient client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync(() => client.SendRequestAsync("Method4", new() { Name = "tool" }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.SendRequestAsync("Method4", new() { Name = "tool" }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -304,7 +304,7 @@ public async Task GetPromptsAsync_HonorsJsonSerializerOptions() JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; await using IMcpClient client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync(() => client.GetPromptAsync("Prompt", new Dictionary { ["i"] = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.GetPromptAsync("Prompt", new Dictionary { ["i"] = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs new file mode 100644 index 00000000..e442883a --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs @@ -0,0 +1,950 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol.Types; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Tests.Client; + +public partial class McpClientResourceTemplateTests : ClientServerTestBase +{ + public McpClientResourceTemplateTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithReadResourceHandler((request, cancellationToken) => + new ValueTask(new ReadResourceResult() + { + Contents = [new TextResourceContents() { Text = request.Params?.Uri ?? string.Empty }] + })); + } + + public static IEnumerable UriTemplate_InputsProduceExpectedOutputs_MemberData() + { + string[] sources = + [ + SpecExamples, + SpecExamplesBySection, + ExtendedTests, + ]; + + foreach (var source in sources) + { + var tests = JsonSerializer.Deserialize(source, JsonContext7.Default.DictionaryStringTestGroup); + Assert.NotNull(tests); + + foreach (var testGroup in tests.Values) + { + Dictionary variables = []; + foreach (var entry in testGroup.Variables) + { + variables[entry.Key] = entry.Value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.String => entry.Value.GetString(), + JsonValueKind.Number => entry.Value.GetDouble(), + JsonValueKind.Array => entry.Value.EnumerateArray().Select(i => i.GetString()).ToArray(), + JsonValueKind.Object => entry.Value.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()), + _ => throw new Exception($"Invalid test case format: {entry.Value.ValueKind}") + }; + } + + foreach (var testCase in testGroup.TestCases) + { + string uriTemplate = testCase[0].GetString() ?? throw new Exception("Invalid test case format."); + object expected = testCase[1].ValueKind switch + { + JsonValueKind.String => testCase[1].GetString()!, + JsonValueKind.Array => testCase[1].EnumerateArray().Select(i => i.GetString()).ToArray(), + JsonValueKind.False => false, + _ => throw new Exception("Invalid test case format.") + }; + + yield return new object[] { variables, uriTemplate, expected }; + } + } + } + } + + [Theory] + [MemberData(nameof(UriTemplate_InputsProduceExpectedOutputs_MemberData))] + public async Task UriTemplate_InputsProduceExpectedOutputs( + IReadOnlyDictionary variables, string uriTemplate, object expected) + { + await using IMcpClient client = await CreateMcpClientForServer(); + + var result = await client.ReadResourceAsync(uriTemplate, variables, TestContext.Current.CancellationToken); + Assert.NotNull(result); + var actualUri = Assert.IsType(Assert.Single(result.Contents)).Text; + + if (expected is string expectedUri) + { + Assert.Equal(expectedUri, actualUri); + } + else + { + Assert.Contains(actualUri, Assert.IsType(expected)); + } + } + + public class TestGroup + { + [JsonPropertyName("level")] + public int Level { get; set; } = 4; + + [JsonPropertyName("variables")] + public Dictionary Variables { get; set; } = []; + + [JsonPropertyName("testcases")] + public List> TestCases { get; set; } = []; + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] + [JsonSerializable(typeof(Dictionary))] + internal partial class JsonContext7 : JsonSerializerContext; + + // The following data comes from: + // https://github.com/uri-templates/uritemplate-test/tree/1eb27ab4462b9e5819dc47db99044f5fd1fa9bc7 + // The JSON from the test case files has been extracted below. + + // Copyright 2011- The Authors + // + // Licensed under the Apache License, Version 2.0 (the "License"); + // you may not use this file except in compliance with the License. + // You may obtain a copy of the License at + // + // http://www.apache.org/licenses/LICENSE-2.0 + // + // Unless required by applicable law or agreed to in writing, software + // distributed under the License is distributed on an "AS IS" BASIS, + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + // See the License for the specific language governing permissions and + // limitations under the License. + + private const string SpecExamples = """ + { + "Level 1 Examples" : + { + "level": 1, + "variables": { + "var" : "value", + "hello" : "Hello World!" + }, + "testcases" : [ + ["{var}", "value"], + ["'{var}'", "'value'"], + ["{hello}", "Hello%20World%21"] + ] + }, + "Level 2 Examples" : + { + "level": 2, + "variables": { + "var" : "value", + "hello" : "Hello World!", + "path" : "/foo/bar" + }, + "testcases" : [ + ["{+var}", "value"], + ["{+hello}", "Hello%20World!"], + ["{+path}/here", "/foo/bar/here"], + ["here?ref={+path}", "here?ref=/foo/bar"] + ] + }, + "Level 3 Examples" : + { + "level": 3, + "variables": { + "var" : "value", + "hello" : "Hello World!", + "empty" : "", + "path" : "/foo/bar", + "x" : "1024", + "y" : "768" + }, + "testcases" : [ + ["map?{x,y}", "map?1024,768"], + ["{x,hello,y}", "1024,Hello%20World%21,768"], + ["{+x,hello,y}", "1024,Hello%20World!,768"], + ["{+path,x}/here", "/foo/bar,1024/here"], + ["{#x,hello,y}", "#1024,Hello%20World!,768"], + ["{#path,x}/here", "#/foo/bar,1024/here"], + ["X{.var}", "X.value"], + ["X{.x,y}", "X.1024.768"], + ["{/var}", "/value"], + ["{/var,x}/here", "/value/1024/here"], + ["{;x,y}", ";x=1024;y=768"], + ["{;x,y,empty}", ";x=1024;y=768;empty"], + ["{?x,y}", "?x=1024&y=768"], + ["{?x,y,empty}", "?x=1024&y=768&empty="], + ["?fixed=yes{&x}", "?fixed=yes&x=1024"], + ["{&x,y,empty}", "&x=1024&y=768&empty="] + ] + }, + "Level 4 Examples" : + { + "level": 4, + "variables": { + "var": "value", + "hello": "Hello World!", + "path": "/foo/bar", + "list": ["red", "green", "blue"], + "keys": {"semi": ";", "dot": ".", "comma":","} + }, + "testcases": [ + ["{var:3}", "val"], + ["{var:30}", "value"], + ["{list}", "red,green,blue"], + ["{list*}", "red,green,blue"], + ["{keys}", [ + "comma,%2C,dot,.,semi,%3B", + "comma,%2C,semi,%3B,dot,.", + "dot,.,comma,%2C,semi,%3B", + "dot,.,semi,%3B,comma,%2C", + "semi,%3B,comma,%2C,dot,.", + "semi,%3B,dot,.,comma,%2C" + ]], + ["{keys*}", [ + "comma=%2C,dot=.,semi=%3B", + "comma=%2C,semi=%3B,dot=.", + "dot=.,comma=%2C,semi=%3B", + "dot=.,semi=%3B,comma=%2C", + "semi=%3B,comma=%2C,dot=.", + "semi=%3B,dot=.,comma=%2C" + ]], + ["{+path:6}/here", "/foo/b/here"], + ["{+list}", "red,green,blue"], + ["{+list*}", "red,green,blue"], + ["{+keys}", [ + "comma,,,dot,.,semi,;", + "comma,,,semi,;,dot,.", + "dot,.,comma,,,semi,;", + "dot,.,semi,;,comma,,", + "semi,;,comma,,,dot,.", + "semi,;,dot,.,comma,," + ]], + ["{+keys*}", [ + "comma=,,dot=.,semi=;", + "comma=,,semi=;,dot=.", + "dot=.,comma=,,semi=;", + "dot=.,semi=;,comma=,", + "semi=;,comma=,,dot=.", + "semi=;,dot=.,comma=," + ]], + ["{#path:6}/here", "#/foo/b/here"], + ["{#list}", "#red,green,blue"], + ["{#list*}", "#red,green,blue"], + ["{#keys}", [ + "#comma,,,dot,.,semi,;", + "#comma,,,semi,;,dot,.", + "#dot,.,comma,,,semi,;", + "#dot,.,semi,;,comma,,", + "#semi,;,comma,,,dot,.", + "#semi,;,dot,.,comma,," + ]], + ["{#keys*}", [ + "#comma=,,dot=.,semi=;", + "#comma=,,semi=;,dot=.", + "#dot=.,comma=,,semi=;", + "#dot=.,semi=;,comma=,", + "#semi=;,comma=,,dot=.", + "#semi=;,dot=.,comma=," + ]], + ["X{.var:3}", "X.val"], + ["X{.list}", "X.red,green,blue"], + ["X{.list*}", "X.red.green.blue"], + ["X{.keys}", [ + "X.comma,%2C,dot,.,semi,%3B", + "X.comma,%2C,semi,%3B,dot,.", + "X.dot,.,comma,%2C,semi,%3B", + "X.dot,.,semi,%3B,comma,%2C", + "X.semi,%3B,comma,%2C,dot,.", + "X.semi,%3B,dot,.,comma,%2C" + ]], + ["{/var:1,var}", "/v/value"], + ["{/list}", "/red,green,blue"], + ["{/list*}", "/red/green/blue"], + ["{/list*,path:4}", "/red/green/blue/%2Ffoo"], + ["{/keys}", [ + "/comma,%2C,dot,.,semi,%3B", + "/comma,%2C,semi,%3B,dot,.", + "/dot,.,comma,%2C,semi,%3B", + "/dot,.,semi,%3B,comma,%2C", + "/semi,%3B,comma,%2C,dot,.", + "/semi,%3B,dot,.,comma,%2C" + ]], + ["{/keys*}", [ + "/comma=%2C/dot=./semi=%3B", + "/comma=%2C/semi=%3B/dot=.", + "/dot=./comma=%2C/semi=%3B", + "/dot=./semi=%3B/comma=%2C", + "/semi=%3B/comma=%2C/dot=.", + "/semi=%3B/dot=./comma=%2C" + ]], + ["{;hello:5}", ";hello=Hello"], + ["{;list}", ";list=red,green,blue"], + ["{;list*}", ";list=red;list=green;list=blue"], + ["{;keys}", [ + ";keys=comma,%2C,dot,.,semi,%3B", + ";keys=comma,%2C,semi,%3B,dot,.", + ";keys=dot,.,comma,%2C,semi,%3B", + ";keys=dot,.,semi,%3B,comma,%2C", + ";keys=semi,%3B,comma,%2C,dot,.", + ";keys=semi,%3B,dot,.,comma,%2C" + ]], + ["{;keys*}", [ + ";comma=%2C;dot=.;semi=%3B", + ";comma=%2C;semi=%3B;dot=.", + ";dot=.;comma=%2C;semi=%3B", + ";dot=.;semi=%3B;comma=%2C", + ";semi=%3B;comma=%2C;dot=.", + ";semi=%3B;dot=.;comma=%2C" + ]], + ["{?var:3}", "?var=val"], + ["{?list}", "?list=red,green,blue"], + ["{?list*}", "?list=red&list=green&list=blue"], + ["{?keys}", [ + "?keys=comma,%2C,dot,.,semi,%3B", + "?keys=comma,%2C,semi,%3B,dot,.", + "?keys=dot,.,comma,%2C,semi,%3B", + "?keys=dot,.,semi,%3B,comma,%2C", + "?keys=semi,%3B,comma,%2C,dot,.", + "?keys=semi,%3B,dot,.,comma,%2C" + ]], + ["{?keys*}", [ + "?comma=%2C&dot=.&semi=%3B", + "?comma=%2C&semi=%3B&dot=.", + "?dot=.&comma=%2C&semi=%3B", + "?dot=.&semi=%3B&comma=%2C", + "?semi=%3B&comma=%2C&dot=.", + "?semi=%3B&dot=.&comma=%2C" + ]], + ["{&var:3}", "&var=val"], + ["{&list}", "&list=red,green,blue"], + ["{&list*}", "&list=red&list=green&list=blue"], + ["{&keys}", [ + "&keys=comma,%2C,dot,.,semi,%3B", + "&keys=comma,%2C,semi,%3B,dot,.", + "&keys=dot,.,comma,%2C,semi,%3B", + "&keys=dot,.,semi,%3B,comma,%2C", + "&keys=semi,%3B,comma,%2C,dot,.", + "&keys=semi,%3B,dot,.,comma,%2C" + ]], + ["{&keys*}", [ + "&comma=%2C&dot=.&semi=%3B", + "&comma=%2C&semi=%3B&dot=.", + "&dot=.&comma=%2C&semi=%3B", + "&dot=.&semi=%3B&comma=%2C", + "&semi=%3B&comma=%2C&dot=.", + "&semi=%3B&dot=.&comma=%2C" + ]] + ] + } + } + """; + + private const string SpecExamplesBySection = """ + { + "2.1 Literals" : + { + "variables": { + "count" : ["one", "two", "three"] + }, + "testcases" : [ + ["'{count}'", "'one,two,three'"] + ] + }, + "3.2.1 Variable Expansion" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{count}", "one,two,three"], + ["{count*}", "one,two,three"], + ["{/count}", "/one,two,three"], + ["{/count*}", "/one/two/three"], + ["{;count}", ";count=one,two,three"], + ["{;count*}", ";count=one;count=two;count=three"], + ["{?count}", "?count=one,two,three"], + ["{?count*}", "?count=one&count=two&count=three"], + ["{&count*}", "&count=one&count=two&count=three"] + ] + }, + "3.2.2 Simple String Expansion" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{var}", "value"], + ["{hello}", "Hello%20World%21"], + ["{half}", "50%25"], + ["O{empty}X", "OX"], + ["O{undef}X", "OX"], + ["{x,y}", "1024,768"], + ["{x,hello,y}", "1024,Hello%20World%21,768"], + ["?{x,empty}", "?1024,"], + ["?{x,undef}", "?1024"], + ["?{undef,y}", "?768"], + ["{var:3}", "val"], + ["{var:30}", "value"], + ["{list}", "red,green,blue"], + ["{list*}", "red,green,blue"], + ["{keys}", [ + "comma,%2C,dot,.,semi,%3B", + "comma,%2C,semi,%3B,dot,.", + "dot,.,comma,%2C,semi,%3B", + "dot,.,semi,%3B,comma,%2C", + "semi,%3B,comma,%2C,dot,.", + "semi,%3B,dot,.,comma,%2C" + ]], + ["{keys*}", [ + "comma=%2C,dot=.,semi=%3B", + "comma=%2C,semi=%3B,dot=.", + "dot=.,comma=%2C,semi=%3B", + "dot=.,semi=%3B,comma=%2C", + "semi=%3B,comma=%2C,dot=.", + "semi=%3B,dot=.,comma=%2C" + ]] + ] + }, + "3.2.3 Reserved Expansion" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{+var}", "value"], + ["{/var,empty}", "/value/"], + ["{/var,undef}", "/value"], + ["{+hello}", "Hello%20World!"], + ["{+half}", "50%25"], + ["{base}index", "http%3A%2F%2Fexample.com%2Fhome%2Findex"], + ["{+base}index", "http://example.com/home/index"], + ["O{+empty}X", "OX"], + ["O{+undef}X", "OX"], + ["{+path}/here", "/foo/bar/here"], + ["{+path:6}/here", "/foo/b/here"], + ["here?ref={+path}", "here?ref=/foo/bar"], + ["up{+path}{var}/here", "up/foo/barvalue/here"], + ["{+x,hello,y}", "1024,Hello%20World!,768"], + ["{+path,x}/here", "/foo/bar,1024/here"], + ["{+list}", "red,green,blue"], + ["{+list*}", "red,green,blue"], + ["{+keys}", [ + "comma,,,dot,.,semi,;", + "comma,,,semi,;,dot,.", + "dot,.,comma,,,semi,;", + "dot,.,semi,;,comma,,", + "semi,;,comma,,,dot,.", + "semi,;,dot,.,comma,," + ]], + ["{+keys*}", [ + "comma=,,dot=.,semi=;", + "comma=,,semi=;,dot=.", + "dot=.,comma=,,semi=;", + "dot=.,semi=;,comma=,", + "semi=;,comma=,,dot=.", + "semi=;,dot=.,comma=," + ]] + ] + }, + "3.2.4 Fragment Expansion" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{#var}", "#value"], + ["{#hello}", "#Hello%20World!"], + ["{#half}", "#50%25"], + ["foo{#empty}", "foo#"], + ["foo{#undef}", "foo"], + ["{#x,hello,y}", "#1024,Hello%20World!,768"], + ["{#path,x}/here", "#/foo/bar,1024/here"], + ["{#path:6}/here", "#/foo/b/here"], + ["{#list}", "#red,green,blue"], + ["{#list*}", "#red,green,blue"], + ["{#keys}", [ + "#comma,,,dot,.,semi,;", + "#comma,,,semi,;,dot,.", + "#dot,.,comma,,,semi,;", + "#dot,.,semi,;,comma,,", + "#semi,;,comma,,,dot,.", + "#semi,;,dot,.,comma,," + ]] + ] + }, + "3.2.5 Label Expansion with Dot-Prefix" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{.who}", ".fred"], + ["{.who,who}", ".fred.fred"], + ["{.half,who}", ".50%25.fred"], + ["www{.dom*}", "www.example.com"], + ["X{.var}", "X.value"], + ["X{.var:3}", "X.val"], + ["X{.empty}", "X."], + ["X{.undef}", "X"], + ["X{.list}", "X.red,green,blue"], + ["X{.list*}", "X.red.green.blue"], + ["{#keys}", [ + "#comma,,,dot,.,semi,;", + "#comma,,,semi,;,dot,.", + "#dot,.,comma,,,semi,;", + "#dot,.,semi,;,comma,,", + "#semi,;,comma,,,dot,.", + "#semi,;,dot,.,comma,," + ]], + ["{#keys*}", [ + "#comma=,,dot=.,semi=;", + "#comma=,,semi=;,dot=.", + "#dot=.,comma=,,semi=;", + "#dot=.,semi=;,comma=,", + "#semi=;,comma=,,dot=.", + "#semi=;,dot=.,comma=," + ]], + ["X{.empty_keys}", "X"], + ["X{.empty_keys*}", "X"] + ] + }, + "3.2.6 Path Segment Expansion" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{/who}", "/fred"], + ["{/who,who}", "/fred/fred"], + ["{/half,who}", "/50%25/fred"], + ["{/who,dub}", "/fred/me%2Ftoo"], + ["{/var}", "/value"], + ["{/var,empty}", "/value/"], + ["{/var,undef}", "/value"], + ["{/var,x}/here", "/value/1024/here"], + ["{/var:1,var}", "/v/value"], + ["{/list}", "/red,green,blue"], + ["{/list*}", "/red/green/blue"], + ["{/list*,path:4}", "/red/green/blue/%2Ffoo"], + ["{/keys}", [ + "/comma,%2C,dot,.,semi,%3B", + "/comma,%2C,semi,%3B,dot,.", + "/dot,.,comma,%2C,semi,%3B", + "/dot,.,semi,%3B,comma,%2C", + "/semi,%3B,comma,%2C,dot,.", + "/semi,%3B,dot,.,comma,%2C" + ]], + ["{/keys*}", [ + "/comma=%2C/dot=./semi=%3B", + "/comma=%2C/semi=%3B/dot=.", + "/dot=./comma=%2C/semi=%3B", + "/dot=./semi=%3B/comma=%2C", + "/semi=%3B/comma=%2C/dot=.", + "/semi=%3B/dot=./comma=%2C" + ]] + ] + }, + "3.2.7 Path-Style Parameter Expansion" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{;who}", ";who=fred"], + ["{;half}", ";half=50%25"], + ["{;empty}", ";empty"], + ["{;hello:5}", ";hello=Hello"], + ["{;v,empty,who}", ";v=6;empty;who=fred"], + ["{;v,bar,who}", ";v=6;who=fred"], + ["{;x,y}", ";x=1024;y=768"], + ["{;x,y,empty}", ";x=1024;y=768;empty"], + ["{;x,y,undef}", ";x=1024;y=768"], + ["{;list}", ";list=red,green,blue"], + ["{;list*}", ";list=red;list=green;list=blue"], + ["{;keys}", [ + ";keys=comma,%2C,dot,.,semi,%3B", + ";keys=comma,%2C,semi,%3B,dot,.", + ";keys=dot,.,comma,%2C,semi,%3B", + ";keys=dot,.,semi,%3B,comma,%2C", + ";keys=semi,%3B,comma,%2C,dot,.", + ";keys=semi,%3B,dot,.,comma,%2C" + ]], + ["{;keys*}", [ + ";comma=%2C;dot=.;semi=%3B", + ";comma=%2C;semi=%3B;dot=.", + ";dot=.;comma=%2C;semi=%3B", + ";dot=.;semi=%3B;comma=%2C", + ";semi=%3B;comma=%2C;dot=.", + ";semi=%3B;dot=.;comma=%2C" + ]] + ] + }, + "3.2.8 Form-Style Query Expansion" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{?who}", "?who=fred"], + ["{?half}", "?half=50%25"], + ["{?x,y}", "?x=1024&y=768"], + ["{?x,y,empty}", "?x=1024&y=768&empty="], + ["{?x,y,undef}", "?x=1024&y=768"], + ["{?var:3}", "?var=val"], + ["{?list}", "?list=red,green,blue"], + ["{?list*}", "?list=red&list=green&list=blue"], + ["{?keys}", [ + "?keys=comma,%2C,dot,.,semi,%3B", + "?keys=comma,%2C,semi,%3B,dot,.", + "?keys=dot,.,comma,%2C,semi,%3B", + "?keys=dot,.,semi,%3B,comma,%2C", + "?keys=semi,%3B,comma,%2C,dot,.", + "?keys=semi,%3B,dot,.,comma,%2C" + ]], + ["{?keys*}", [ + "?comma=%2C&dot=.&semi=%3B", + "?comma=%2C&semi=%3B&dot=.", + "?dot=.&comma=%2C&semi=%3B", + "?dot=.&semi=%3B&comma=%2C", + "?semi=%3B&comma=%2C&dot=.", + "?semi=%3B&dot=.&comma=%2C" + ]] + ] + }, + "3.2.9 Form-Style Query Continuation" : + { + "variables": { + "count" : ["one", "two", "three"], + "dom" : ["example", "com"], + "dub" : "me/too", + "hello" : "Hello World!", + "half" : "50%", + "var" : "value", + "who" : "fred", + "base" : "http://example.com/home/", + "path" : "/foo/bar", + "list" : ["red", "green", "blue"], + "keys" : { "semi" : ";", "dot" : ".", "comma" : ","}, + "v" : "6", + "x" : "1024", + "y" : "768", + "empty" : "", + "empty_keys" : {}, + "undef" : null + }, + "testcases" : [ + ["{&who}", "&who=fred"], + ["{&half}", "&half=50%25"], + ["?fixed=yes{&x}", "?fixed=yes&x=1024"], + ["{&var:3}", "&var=val"], + ["{&x,y,empty}", "&x=1024&y=768&empty="], + ["{&x,y,undef}", "&x=1024&y=768"], + ["{&list}", "&list=red,green,blue"], + ["{&list*}", "&list=red&list=green&list=blue"], + ["{&keys}", [ + "&keys=comma,%2C,dot,.,semi,%3B", + "&keys=comma,%2C,semi,%3B,dot,.", + "&keys=dot,.,comma,%2C,semi,%3B", + "&keys=dot,.,semi,%3B,comma,%2C", + "&keys=semi,%3B,comma,%2C,dot,.", + "&keys=semi,%3B,dot,.,comma,%2C" + ]], + ["{&keys*}", [ + "&comma=%2C&dot=.&semi=%3B", + "&comma=%2C&semi=%3B&dot=.", + "&dot=.&comma=%2C&semi=%3B", + "&dot=.&semi=%3B&comma=%2C", + "&semi=%3B&comma=%2C&dot=.", + "&semi=%3B&dot=.&comma=%2C" + ]] + ] + } + } + """; + + private const string ExtendedTests = """ + { + "Additional Examples 1":{ + "level":4, + "variables":{ + "id" : "person", + "token" : "12345", + "fields" : ["id", "name", "picture"], + "format" : "json", + "q" : "URI Templates", + "page" : "5", + "lang" : "en", + "geocode" : ["37.76","-122.427"], + "first_name" : "John", + "last.name" : "Doe", + "Some%20Thing" : "foo", + "number" : 6, + "long" : 37.76, + "lat" : -122.427, + "group_id" : "12345", + "query" : "PREFIX dc: SELECT ?book ?who WHERE { ?book dc:creator ?who }", + "uri" : "http://example.org/?uri=http%3A%2F%2Fexample.org%2F", + "word" : "drücken", + "Stra%C3%9Fe" : "Grüner Weg", + "random" : "šö䟜ñꀣ¥‡ÑÒÓÔÕÖרÙÚàáâãäåæçÿ", + "assoc_special_chars" : + { "šö䟜ñꀣ¥‡ÑÒÓÔÕ" : "ÖרÙÚàáâãäåæçÿ" } + }, + "testcases":[ + + [ "{/id*}" , "/person" ], + [ "{/id*}{?fields,first_name,last.name,token}","/person?fields=id,name,picture&first_name=John&last.name=Doe&token=12345"], + ["/search.{format}{?q,geocode,lang,locale,page,result_type}","/search.json?q=URI%20Templates&geocode=37.76,-122.427&lang=en&page=5"], + ["/test{/Some%20Thing}", "/test/foo" ], + ["/set{?number}", "/set?number=6"], + ["/loc{?long,lat}" , "/loc?long=37.76&lat=-122.427"], + ["/base{/group_id,first_name}/pages{/page,lang}{?format,q}","/base/12345/John/pages/5/en?format=json&q=URI%20Templates"], + ["/sparql{?query}", "/sparql?query=PREFIX%20dc%3A%20%3Chttp%3A%2F%2Fpurl.org%2Fdc%2Felements%2F1.1%2F%3E%20SELECT%20%3Fbook%20%3Fwho%20WHERE%20%7B%20%3Fbook%20dc%3Acreator%20%3Fwho%20%7D"], + ["/go{?uri}", "/go?uri=http%3A%2F%2Fexample.org%2F%3Furi%3Dhttp%253A%252F%252Fexample.org%252F"], + ["/service{?word}", "/service?word=dr%C3%BCcken"], + ["/lookup{?Stra%C3%9Fe}", "/lookup?Stra%C3%9Fe=Gr%C3%BCner%20Weg"], + ["{random}" , "%C5%A1%C3%B6%C3%A4%C5%B8%C5%93%C3%B1%C3%AA%E2%82%AC%C2%A3%C2%A5%E2%80%A1%C3%91%C3%92%C3%93%C3%94%C3%95%C3%96%C3%97%C3%98%C3%99%C3%9A%C3%A0%C3%A1%C3%A2%C3%A3%C3%A4%C3%A5%C3%A6%C3%A7%C3%BF"], + ["{?assoc_special_chars*}", "?%C5%A1%C3%B6%C3%A4%C5%B8%C5%93%C3%B1%C3%AA%E2%82%AC%C2%A3%C2%A5%E2%80%A1%C3%91%C3%92%C3%93%C3%94%C3%95=%C3%96%C3%97%C3%98%C3%99%C3%9A%C3%A0%C3%A1%C3%A2%C3%A3%C3%A4%C3%A5%C3%A6%C3%A7%C3%BF"] + ] + }, + "Additional Examples 2":{ + "level":4, + "variables":{ + "id" : ["person","albums"], + "token" : "12345", + "fields" : ["id", "name", "picture"], + "format" : "atom", + "q" : "URI Templates", + "page" : "10", + "start" : "5", + "lang" : "en", + "geocode" : ["37.76","-122.427"] + }, + "testcases":[ + + [ "{/id*}" , "/person/albums" ], + [ "{/id*}{?fields,token}" , "/person/albums?fields=id,name,picture&token=12345" ] + ] + }, + "Additional Examples 3: Empty Variables":{ + "variables" : { + "empty_list" : [], + "empty_assoc" : {} + }, + "testcases":[ + [ "{/empty_list}", [ "" ] ], + [ "{/empty_list*}", [ "" ] ], + [ "{?empty_list}", [ ""] ], + [ "{?empty_list*}", [ "" ] ], + [ "{?empty_assoc}", [ "" ] ], + [ "{?empty_assoc*}", [ "" ] ] + ] + }, + "Additional Examples 4: Numeric Keys":{ + "variables" : { + "42" : "The Answer to the Ultimate Question of Life, the Universe, and Everything", + "1337" : ["leet", "as","it", "can","be"], + "german" : { + "11": "elf", + "12": "zwölf" + } + }, + "testcases":[ + [ "{42}", "The%20Answer%20to%20the%20Ultimate%20Question%20of%20Life%2C%20the%20Universe%2C%20and%20Everything"], + [ "{?42}", "?42=The%20Answer%20to%20the%20Ultimate%20Question%20of%20Life%2C%20the%20Universe%2C%20and%20Everything"], + [ "{1337}", "leet,as,it,can,be"], + [ "{?1337*}", "?1337=leet&1337=as&1337=it&1337=can&1337=be"], + [ "{?german*}", [ "?11=elf&12=zw%C3%B6lf", "?12=zw%C3%B6lf&11=elf"] ] + ] + }, + "Additional Examples 5: Explode Combinations":{ + "variables" : { + "id" : "admin", + "token" : "12345", + "tab" : "overview", + "keys" : { + "key1": "val1", + "key2": "val2" + } + }, + "testcases":[ + [ "{?id,token,keys*}", [ + "?id=admin&token=12345&key1=val1&key2=val2", + "?id=admin&token=12345&key2=val2&key1=val1"] + ], + [ "{/id}{?token,keys*}", [ + "/admin?token=12345&key1=val1&key2=val2", + "/admin?token=12345&key2=val2&key1=val1"] + ], + [ "{?id,token}{&keys*}", [ + "?id=admin&token=12345&key1=val1&key2=val2", + "?id=admin&token=12345&key2=val2&key1=val1"] + ], + [ "/user{/id}{?token,tab}{&keys*}", [ + "/user/admin?token=12345&tab=overview&key1=val1&key2=val2", + "/user/admin?token=12345&tab=overview&key2=val2&key1=val1"] + ] + ] + }, + "Additional Examples 6: Reserved Expansion":{ + "variables" : { + "id" : "admin%2F", + "not_pct" : "%foo", + "list" : ["red%25", "%2Fgreen", "blue "], + "keys" : { + "key1": "val1%2F", + "key2": "val2%2F" + } + }, + "testcases": [ + ["{+id}", "admin%2F"], + ["{#id}", "#admin%2F"], + ["{id}", "admin%252F"], + ["{+not_pct}", "%25foo"], + ["{#not_pct}", "#%25foo"], + ["{not_pct}", "%25foo"], + ["{+list}", "red%25,%2Fgreen,blue%20"], + ["{#list}", "#red%25,%2Fgreen,blue%20"], + ["{list}", "red%2525,%252Fgreen,blue%20"], + ["{+keys}", "key1,val1%2F,key2,val2%2F"], + ["{#keys}", "#key1,val1%2F,key2,val2%2F"], + ["{keys}", "key1,val1%252F,key2,val2%252F"] + ] + } + } + """; +} \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index dc01c92c..3e1ac8eb 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -174,8 +174,8 @@ public async Task GetPrompt_NonExistent_ThrowsException(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - await Assert.ThrowsAsync(() => - client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => + await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); } [Theory] @@ -187,7 +187,7 @@ public async Task ListResourceTemplates_Stdio(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - IList allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken); + IList allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken); // The server provides a single test resource template Assert.Single(allResourceTemplates); @@ -202,7 +202,7 @@ public async Task ListResources_Stdio(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - IList allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + IList allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); // The server provides 100 test resources Assert.Equal(100, allResources.Count); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 876fe03e..fd33f1e2 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -664,7 +664,7 @@ await client.SendNotificationAsync( }, cancellationToken: TestContext.Current.CancellationToken); - await Assert.ThrowsAnyAsync(() => invokeTask); + await Assert.ThrowsAnyAsync(async () => await invokeTask); } [McpServerToolType] diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index 3fe69275..490b792c 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -87,7 +87,7 @@ public async Task Session_FailedToolCall() await RunConnected(async (client, server) => { await client.CallToolAsync("Throw", cancellationToken: TestContext.Current.CancellationToken); - await Assert.ThrowsAsync(() => client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken)); }, new List()); } diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs index 6aa05818..ae3f88ac 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs @@ -42,7 +42,7 @@ public async Task PrecancelRequest_CancelsBeforeSending() return default; })) { - await Assert.ThrowsAsync(() => client.ListToolsAsync(cancellationToken: new CancellationToken(true))); + await Assert.ThrowsAsync(async () => await client.ListToolsAsync(cancellationToken: new CancellationToken(true))); } Assert.False(gotCancellation); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs index 3f56c4a5..93a76e1a 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs @@ -5,7 +5,6 @@ using Moq; using System.ComponentModel; using System.Reflection; -using System.Text.Json; namespace ModelContextProtocol.Tests.Server; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs index 8cf4a3c9..0c765f47 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs @@ -1,25 +1,29 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; +using Moq; +using System.Reflection; +using System.Text.Json.Serialization; namespace ModelContextProtocol.Tests.Server; -public class McpServerResourceTests +public partial class McpServerResourceTests { [Fact] - public void CanCreateServerWithResourceTemplates() + public void CanCreateServerWithResource() { var services = new ServiceCollection(); services.AddMcpServer() .WithStdioServerTransport() - .WithListResourceTemplatesHandler(async (ctx, ct) => + .WithListResourcesHandler(async (ctx, ct) => { - return new ListResourceTemplatesResult + return new ListResourcesResult { - ResourceTemplates = + Resources = [ - new ResourceTemplate { Name = "Static Resource", Description = "A static resource with a numeric ID", UriTemplate = "test://static/resource/{id}" } + new Resource { Name = "Static Resource", Description = "A static resource with a numeric ID", Uri = "test://static/resource" } ] }; }) @@ -41,20 +45,21 @@ public void CanCreateServerWithResourceTemplates() provider.GetRequiredService(); } + [Fact] - public void CanCreateServerWithResources() + public void CanCreateServerWithResourceTemplates() { var services = new ServiceCollection(); services.AddMcpServer() .WithStdioServerTransport() - .WithListResourcesHandler(async (ctx, ct) => + .WithListResourceTemplatesHandler(async (ctx, ct) => { - return new ListResourcesResult + return new ListResourceTemplatesResult { - Resources = + ResourceTemplates = [ - new Resource { Name = "Static Resource", Description = "A static resource with a numeric ID", Uri = "test://static/resource/foo.txt" } + new ResourceTemplate { Name = "Static Resource", Description = "A static resource with a numeric ID", UriTemplate = "test://static/resource/{id}" } ] }; }) @@ -77,7 +82,7 @@ public void CanCreateServerWithResources() } [Fact] - public void CreatingReadHandlerWithNoListHandlerFails() + public void CreatingReadHandlerWithNoListHandlerSucceeds() { var services = new ServiceCollection(); services.AddMcpServer() @@ -95,6 +100,480 @@ public void CreatingReadHandlerWithNoListHandlerFails() }; }); var sp = services.BuildServiceProvider(); - Assert.Throws(sp.GetRequiredService); + + sp.GetRequiredService(); + } + + [Fact] + public void Create_InvalidArgs_Throws() + { + Assert.Throws("function", () => McpServerResource.Create((AIFunction)null!, new() { UriTemplate = "test://hello" })); + Assert.Throws("method", () => McpServerResource.Create((MethodInfo)null!)); + Assert.Throws("method", () => McpServerResource.Create((MethodInfo)null!, typeof(object))); + Assert.Throws("targetType", () => McpServerResource.Create(typeof(McpServerResourceTests).GetMethod(nameof(Create_InvalidArgs_Throws))!, (Type)null!)); + Assert.Throws("method", () => McpServerResource.Create((Delegate)null!)); + + Assert.NotNull(McpServerResource.Create(typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.InstanceMethod))!, new DisposableResourceType())); + Assert.NotNull(McpServerResource.Create(typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.StaticMethod))!)); + Assert.Throws("target", () => McpServerResource.Create(typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.InstanceMethod))!, target: null!)); + } + + [Fact] + public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported() + { + const string Name = "Hello"; + McpServerResource t; + ReadResourceResult? result; + IMcpServer server = new Mock().Object; + + t = McpServerResource.Create(() => "42", new() { Name = Name }); + Assert.Equal($"resource://{Name}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((IMcpServer server) => "42", new() { Name = Name }); + Assert.Equal($"resource://{Name}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((string arg1) => arg1, new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?arg1}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?arg1=wOrLd" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("wOrLd", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((string arg1, string? arg2 = null) => arg1 + arg2, new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?arg1,arg2}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?arg1=wo&arg2=rld" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("world", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((object a1, bool a2, char a3, byte a4, sbyte a5) => a1.ToString() + a2 + a3 + a4 + a5, new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=hi&a2=true&a3=s&a4=12&a5=34" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("hiTrues1234", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((ushort a1, short a2, uint a3, int a4, ulong a5) => (a1 + a2 + a3 + a4 + (long)a5).ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=10&a2=20&a3=30&a4=40&a5=50" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("150", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((long a1, float a2, double a3, decimal a4, TimeSpan a5) => a5.ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=1&a2=2&a3=3&a4=4&a5=5" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("5.00:00:00", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((DateTime a1, DateTimeOffset a2, Uri a3, Guid a4, Version a5) => a4.ToString("N") + a5, new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a3=http%3A%2F%2Ftest&a4=14e5f43d-0d41-47d6-8207-8249cf669e41&a5=1.2.3.4" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("14e5f43d0d4147d682078249cf669e411.2.3.4", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((Half a2, Int128 a3, UInt128 a4, IntPtr a5) => (a3 + (Int128)a4 + a5).ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a2=1.0&a3=3&a4=4&a5=5" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("12", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((UIntPtr a1, DateOnly a2, TimeOnly a3) => a1.ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("123", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((bool? a2, char? a3, byte? a4, sbyte? a5) => a2?.ToString() + a3 + a4 + a5, new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a2=true&a3=s&a4=12&a5=34" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("Trues1234", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((ushort? a1, short? a2, uint? a3, int? a4, ulong? a5) => (a1 + a2 + a3 + a4 + (long?)a5).ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=10&a2=20&a3=30&a4=40&a5=50" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("150", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((long? a1, float? a2, double? a3, decimal? a4, TimeSpan? a5) => a5?.ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=1&a2=2&a3=3&a4=4&a5=5" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("5.00:00:00", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((DateTime? a1, DateTimeOffset? a2, Guid? a4) => a4?.ToString("N"), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a4}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a4=14e5f43d-0d41-47d6-8207-8249cf669e41" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("14e5f43d0d4147d682078249cf669e41", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((Half? a2, Int128? a3, UInt128? a4, IntPtr? a5) => (a3 + (Int128?)a4 + a5).ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a2=1.0&a3=3&a4=4&a5=5" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("12", ((TextResourceContents)result.Contents[0]).Text); + + t = McpServerResource.Create((UIntPtr? a1, DateOnly? a2, TimeOnly? a3) => a1?.ToString(), new() { Name = Name }); + Assert.Equal($"resource://{Name}{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate); + result = await t.ReadAsync( + new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("123", ((TextResourceContents)result.Contents[0]).Text); + } + + [Theory] + [InlineData("resource://Hello?arg1=42&arg2=84")] + [InlineData("resource://Hello?arg1=42&arg2=84&arg3=123")] + [InlineData("resource://Hello#fragment")] + public async Task UriTemplate_NonMatchingUri_ReturnsNull(string uri) + { + McpServerResource t = McpServerResource.Create((string arg1) => arg1, new() { Name = "Hello" }); + Assert.Equal("resource://Hello{?arg1}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Null(await t.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = uri } }, + TestContext.Current.CancellationToken)); } + + [Theory] + [InlineData("resource://Hello?arg1=test")] + [InlineData("resource://Hello?arg2=test")] + public async Task UriTemplate_MissingParameter_Throws(string uri) + { + McpServerResource t = McpServerResource.Create((string arg1, int arg2) => arg1, new() { Name = "Hello" }); + Assert.Equal("resource://Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate); + await Assert.ThrowsAsync(async () => await t.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = uri } }, + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UriTemplate_MissingOptionalParameter_Succeeds() + { + McpServerResource t = McpServerResource.Create((string? arg1 = null, int? arg2 = null) => arg1 + arg2, new() { Name = "Hello" }); + Assert.Equal("resource://Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate); + + ReadResourceResult? result; + + result = await t.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("", ((TextResourceContents)result.Contents[0]).Text); + + result = await t.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello?arg1=first" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("first", ((TextResourceContents)result.Contents[0]).Text); + + result = await t.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello?arg2=42" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + + result = await t.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello?arg1=first&arg2=42" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("first42", ((TextResourceContents)result.Contents[0]).Text); + } + + [Fact] + public async Task SupportsIMcpServer() + { + Mock mockServer = new(); + + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return "42"; + }, new() { Name = "Test" }); + + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + } + + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task SupportsServiceFromDI(ServiceLifetime injectedArgumentLifetime) + { + MyService singletonService = new(); + + ServiceCollection sc = new(); + switch (injectedArgumentLifetime) + { + case ServiceLifetime.Singleton: + sc.AddSingleton(singletonService); + break; + + case ServiceLifetime.Scoped: + sc.AddScoped(_ => new MyService()); + break; + + case ServiceLifetime.Transient: + sc.AddTransient(_ => new MyService()); + break; + } + + sc.AddSingleton(services => + { + return McpServerResource.Create((MyService actualMyService) => + { + Assert.NotNull(actualMyService); + if (injectedArgumentLifetime == ServiceLifetime.Singleton) + { + Assert.Same(singletonService, actualMyService); + } + + return "42"; + }, new() { Services = services, Name = "Test" }); + }); + + IServiceProvider services = sc.BuildServiceProvider(); + + McpServerResource resource = services.GetRequiredService(); + + Mock mockServer = new(); + + await Assert.ThrowsAsync(async () => await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken)); + + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Services = services, Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + } + + [Fact] + public async Task SupportsOptionalServiceFromDI() + { + MyService expectedMyService = new(); + + ServiceCollection sc = new(); + sc.AddSingleton(expectedMyService); + IServiceProvider services = sc.BuildServiceProvider(); + + McpServerResource resource = McpServerResource.Create((MyService? actualMyService = null) => + { + Assert.Null(actualMyService); + return "42"; + }, new() { Services = services, Name = "Test" }); + + var result = await resource.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + } + + [Fact] + public async Task SupportsDisposingInstantiatedDisposableTargets() + { + int before = DisposableResourceType.Disposals; + + McpServerResource resource1 = McpServerResource.Create( + typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.InstanceMethod))!, + typeof(DisposableResourceType)); + + var result = await resource1.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = "test://static/resource/instanceMethod" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("0", ((TextResourceContents)result.Contents[0]).Text); + + Assert.Equal(1, DisposableResourceType.Disposals); + } + + [Fact] + public async Task CanReturnReadResult() + { + Mock mockServer = new(); + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new ReadResourceResult() { Contents = new List() { new TextResourceContents() { Text = "hello" } } }; + }, new() { Name = "Test" }); + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.Equal("hello", ((TextResourceContents)result.Contents[0]).Text); + } + + [Fact] + public async Task CanReturnResourceContents() + { + Mock mockServer = new(); + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new TextResourceContents() { Text = "hello" }; + }, new() { Name = "Test" }); + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.Equal("hello", ((TextResourceContents)result.Contents[0]).Text); + } + + [Fact] + public async Task CanReturnCollectionOfResourceContents() + { + Mock mockServer = new(); + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new List() + { + new TextResourceContents() { Text = "hello" }, + new BlobResourceContents() { Blob = Convert.ToBase64String(new byte[] { 1, 2, 3 }) }, + }; + }, new() { Name = "Test" }); + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal(2, result.Contents.Count); + Assert.Equal("hello", ((TextResourceContents)result.Contents[0]).Text); + Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), ((BlobResourceContents)result.Contents[1]).Blob); + } + + [Fact] + public async Task CanReturnString() + { + Mock mockServer = new(); + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return "42"; + }, new() { Name = "Test" }); + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + } + + [Fact] + public async Task CanReturnCollectionOfStrings() + { + Mock mockServer = new(); + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new List() { "42", "43" }; + }, new() { Name = "Test" }); + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal(2, result.Contents.Count); + Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); + Assert.Equal("43", ((TextResourceContents)result.Contents[1]).Text); + } + + [Fact] + public async Task CanReturnDataContent() + { + Mock mockServer = new(); + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new DataContent(new byte[] { 0, 1, 2 }, "application/octet-stream"); + }, new() { Name = "Test" }); + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.Equal(Convert.ToBase64String(new byte[] { 0, 1, 2 }), ((BlobResourceContents)result.Contents[0]).Blob); + Assert.Equal("application/octet-stream", ((BlobResourceContents)result.Contents[0]).MimeType); + } + + [Fact] + public async Task CanReturnCollectionOfAIContent() + { + Mock mockServer = new(); + McpServerResource resource = McpServerResource.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new List() + { + new TextContent("hello!"), + new DataContent(new byte[] { 4, 5, 6 }, "application/json"), + }; + }, new() { Name = "Test" }); + var result = await resource.ReadAsync( + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal(2, result.Contents.Count); + Assert.Equal("hello!", ((TextResourceContents)result.Contents[0]).Text); + Assert.Equal(Convert.ToBase64String(new byte[] { 4, 5, 6 }), ((BlobResourceContents)result.Contents[1]).Blob); + Assert.Equal("application/json", ((BlobResourceContents)result.Contents[1]).MimeType); + } + + private sealed class MyService; + + private class DisposableResourceType : IDisposable + { + public static int Disposals { get; private set; } + + public void Dispose() => Disposals++; + + [McpServerResource(UriTemplate = "test://static/resource/instanceMethod")] + public object InstanceMethod() => Disposals.ToString(); + + [McpServerResource(UriTemplate = "test://static/resource/staticMethod")] + public static object StaticMethod() => "42"; + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(DisposableResourceType))] + partial class JsonContext6 : JsonSerializerContext; } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 958fe124..66256352 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -101,7 +101,7 @@ public async Task RequestSamplingAsync_Should_Throw_Exception_If_Client_Does_Not await using var server = McpServerFactory.Create(transport, _options, LoggerFactory); SetClientCapabilities(server, new ClientCapabilities()); - var action = () => server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None); + var action = async () => await server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None); // Act & Assert await Assert.ThrowsAsync(action); @@ -138,7 +138,7 @@ public async Task RequestRootsAsync_Should_Throw_Exception_If_Client_Does_Not_Su SetClientCapabilities(server, new ClientCapabilities()); // Act & Assert - await Assert.ThrowsAsync(() => server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None)); + await Assert.ThrowsAsync(async () => await server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None)); } [Fact] @@ -294,7 +294,7 @@ await Can_Handle_Requests( [Fact] public async Task Can_Handle_Resources_List_Requests_Throws_Exception_If_No_Handler_Assigned() { - await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesList, "ListResources handler not configured"); + await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesList, "ListResources handler not configured"); } [Fact] @@ -331,7 +331,7 @@ await Can_Handle_Requests( [Fact] public async Task Can_Handle_Resources_Read_Requests_Throws_Exception_If_No_Handler_Assigned() { - await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesRead, "ReadResource handler not configured"); + await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesRead, "ReadResource handler not configured"); } [Fact] @@ -366,7 +366,7 @@ await Can_Handle_Requests( [Fact] public async Task Can_Handle_List_Prompts_Requests_Throws_Exception_If_No_Handler_Assigned() { - await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsList, "ListPrompts handler not configured"); + await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsList, "ListPrompts handler not configured"); } [Fact] @@ -394,7 +394,7 @@ await Can_Handle_Requests( [Fact] public async Task Can_Handle_Get_Prompts_Requests_Throws_Exception_If_No_Handler_Assigned() { - await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsGet, "GetPrompt handler not configured"); + await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsGet, "GetPrompt handler not configured"); } [Fact] @@ -429,7 +429,7 @@ await Can_Handle_Requests( [Fact] public async Task Can_Handle_List_Tools_Requests_Throws_Exception_If_No_Handler_Assigned() { - await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsList, "ListTools handler not configured"); + await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsList, "ListTools handler not configured"); } [Fact] @@ -464,7 +464,7 @@ await Can_Handle_Requests( [Fact] public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_Assigned() { - await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured"); + await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured"); } private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action? configureOptions, Action assertResult) @@ -502,12 +502,13 @@ await transport.SendMessageAsync( await runTask; } - private async Task Throws_Exception_If_No_Handler_Assigned(ServerCapabilities serverCapabilities, string method, string expectedError) + private async Task Succeeds_Even_If_No_Handler_Assigned(ServerCapabilities serverCapabilities, string method, string expectedError) { await using var transport = new TestServerTransport(); var options = CreateOptions(serverCapabilities); - Assert.Throws(() => McpServerFactory.Create(transport, options, LoggerFactory)); + var server = McpServerFactory.Create(transport, options, LoggerFactory); + await server.DisposeAsync(); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs index baf22f3d..4efe5ec5 100644 --- a/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs @@ -1,7 +1,6 @@ using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Transport; using ModelContextProtocol.Tests.Utils; -using System.IO.Pipelines; using System.Net; namespace ModelContextProtocol.Tests.Transport;