Add McpServer/ClientResource{Template} and friends (#391)

* Add McpServer/ClientResource{Template} and friends

This PR copies the same design used by tools and prompts to add McpServerResource, McpServerResourceAttribute, McpServerResourceTypeAttribute, McpClientResource, and then all of those for ResourceTemplate as well.

Unlike the others, [McpServerResource] is permitted not only on methods but also on properties. This convenience is afforded because such resources aren't parameterized, and thus can be easily modeled as properties, likely auto-props, e.g.
```C#
[McpServerResource]
public static BlobResourceContents MyAmazingData { get; } = Create();
```

Details about resources can be encoded into the attributes or provided via McpServerResource{Template}.Create methods. If not URI is explicitly defined, one is derived from the member name and arguments. For templates, this means manufacturing a URI template that includes variables for all non-DI parameters.

This also fixes a few issues discovered along the way:
- McpClientExtensions are updated to use `ValueTask<T>` instead of `Task<T>` where relevant.
- McpServerPrompt was missing handling for progress notifications; that's been rectified. It was also missing configuring the description on the ProtocolPrompt.
- Renamed IMcpServerPrimitive.Name to Id. For resources, names aren't unique.
- After going back and forth, updated McpServer to not fail for missing handlers and to instead register default handlers. It simplifies the implementation and makes it more consistent.
- Added a bunch of primitive types into the McpJsonUtilities JSON source generator context so that such primitives are usable with tools/prompts/resources without folks being required to add them into their own context for Native AOT. I think we should consider pushing this down to AIJsonUtilities, instead, and that will then flow up to McpJsonUtilities automatically.

* Address feedback
This commit is contained in:
Stephen Toub 2025-05-09 18:21:11 -04:00 • committed by GitHub
commit d86b3277d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 4146 additions and 308 deletions

View file

@ -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
}
/// <summary>
/// Creates an <see cref="McpServerPrompt"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// Creates an <see cref="McpServerPrompt"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
public static new AIFunctionMcpServerPrompt Create(
MethodInfo method,
@ -44,7 +44,7 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
}
/// <summary>
/// Creates an <see cref="McpServerPrompt"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// Creates an <see cref="McpServerPrompt"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
public static new AIFunctionMcpServerPrompt Create(
MethodInfo method,
@ -89,6 +89,27 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
};
}
if (pi.ParameterType == typeof(IProgress<ProgressNotificationValue>))
{
// Bind IProgress<ProgressNotificationValue> 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<GetPromptRequestParams>? GetRequestContext(AIFunctionArguments args)
@ -138,13 +159,18 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
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<McpServerPromptAttribute>() is { } attr)
if (method.GetCustomAttribute<McpServerPromptAttribute>() is { } promptAttr)
{
newOptions.Name ??= attr.Name;
newOptions.Name ??= promptAttr.Name;
}
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
{
newOptions.Description ??= descAttr.Description;
}
return newOptions;
@ -167,18 +193,6 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
public override Prompt ProtocolPrompt { get; }
/// <inheritdoc />
/// <remarks>
/// This implementation invokes the underlying <see cref="AIFunction"/> with the request arguments, and processes
/// the result to create a standardized <see cref="GetPromptResult"/>. The method supports various return types from
/// the underlying function:
/// <list type="bullet">
/// <item><description>Direct <see cref="GetPromptResult"/> instances are returned as-is</description></item>
/// <item><description>String values are converted to a single user message</description></item>
/// <item><description>Single <see cref="PromptMessage"/> objects are wrapped in a result</description></item>
/// <item><description>Collections of <see cref="PromptMessage"/> objects are combined in a result</description></item>
/// <item><description><see cref="ChatMessage"/> objects are converted to prompt messages</description></item>
/// </list>
/// </remarks>
public override async ValueTask<GetPromptResult> GetAsync(
RequestContext<GetPromptRequestParams> request, CancellationToken cancellationToken = default)
{

View file

@ -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;
/// <summary>Provides an <see cref="AIFunctionMcpServerResource"/> that's implemented via an <see cref="AIFunction"/>.</summary>
internal sealed class AIFunctionMcpServerResource : McpServerResource
{
private readonly Regex? _uriParser;
private readonly string[] _templateVariableNames = [];
/// <summary>
/// Creates an <see cref="AIFunctionMcpServerResource"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
public static new AIFunctionMcpServerResource Create(
Delegate method,
McpServerResourceCreateOptions? options)
{
Throw.IfNull(method);
options = DeriveOptions(method.Method, options);
return Create(method.Method, method.Target, options);
}
/// <summary>
/// Creates an <see cref="McpServerResource"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
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);
}
/// <summary>
/// Creates an <see cref="McpServerResource"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
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<McpServerResourceAttribute>()?.Name,
Description = options?.Description,
MarshalResult = static (result, _, cancellationToken) => new ValueTask<object?>(result),
SerializerOptions = McpJsonUtilities.DefaultOptions,
Services = options?.Services,
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<ReadResourceRequestParams>))
{
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<ProgressNotificationValue>))
{
// Bind IProgress<ProgressNotificationValue> 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<ReadResourceRequestParams>? GetRequestContext(AIFunctionArguments args)
{
if (args.Context?.TryGetValue(typeof(RequestContext<ReadResourceRequestParams>), out var rc) is true &&
rc is RequestContext<ReadResourceRequestParams> requestContext)
{
return requestContext;
}
return null;
}
},
};
private static readonly ConcurrentDictionary<Type, Func<string, object?>> s_convertersCache = [];
private static Func<string, object?>? 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<TypeConverterAttribute>() 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;
}
/// <summary>Creates an <see cref="McpServerResource"/> that wraps the specified <see cref="AIFunction"/>.</summary>
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<McpServerResourceAttribute>() is { } resourceAttr)
{
newOptions.UriTemplate ??= resourceAttr.UriTemplate;
newOptions.Name ??= resourceAttr.Name;
newOptions.MimeType ??= resourceAttr.MimeType;
}
if (member.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
{
newOptions.Description ??= descAttr.Description;
}
return newOptions;
}
/// <summary>Derives a name to be used as a resource name.</summary>
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();
}
/// <summary>Gets the <see cref="AIFunction"/> wrapped by this resource.</summary>
internal AIFunction AIFunction { get; }
/// <summary>Initializes a new instance of the <see cref="McpServerResource"/> class.</summary>
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();
}
}
/// <inheritdoc />
public override string ToString() => AIFunction.ToString();
/// <inheritdoc />
public override ResourceTemplate ProtocolResourceTemplate { get; }
/// <inheritdoc />
public override Resource? ProtocolResource { get; }
/// <inheritdoc />
public override async ValueTask<ReadResourceResult?> ReadAsync(
RequestContext<ReadResourceRequestParams> 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<object, object?>() { [typeof(RequestContext<ReadResourceRequestParams>)] = 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<ResourceContents> contents => new()
{
Contents = contents.ToList(),
},
IEnumerable<AIContent> aiContents => new()
{
Contents = aiContents.Select<AIContent, ResourceContents>(
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<string> strings => new()
{
Contents = strings.Select<string, ResourceContents>(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."),
};
}
}

View file

@ -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
}
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
public static new AIFunctionMcpServerTool Create(
MethodInfo method,
@ -44,7 +44,7 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
}
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
public static new AIFunctionMcpServerTool Create(
MethodInfo method,
@ -160,36 +160,41 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
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<McpServerToolAttribute>() is { } attr)
if (method.GetCustomAttribute<McpServerToolAttribute>() 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<DescriptionAttribute>() is { } descAttr)
{
newOptions.Description ??= descAttr.Description;
}
return newOptions;
}

View file

@ -0,0 +1,35 @@
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
namespace ModelContextProtocol.Server;
/// <summary>Provides an <see cref="McpServerResource"/> that delegates all operations to an inner <see cref="McpServerResource"/>.</summary>
/// <remarks>
/// This is recommended as a base type when building resources that can be chained around an underlying <see cref="McpServerResource"/>.
/// The default implementation simply passes each call to the inner resource instance.
/// </remarks>
public abstract class DelegatingMcpServerResource : McpServerResource
{
private readonly McpServerResource _innerResource;
/// <summary>Initializes a new instance of the <see cref="DelegatingMcpServerResource"/> class around the specified <paramref name="innerResource"/>.</summary>
/// <param name="innerResource">The inner resource wrapped by this delegating resource.</param>
protected DelegatingMcpServerResource(McpServerResource innerResource)
{
Throw.IfNull(innerResource);
_innerResource = innerResource;
}
/// <inheritdoc />
public override Resource? ProtocolResource => _innerResource.ProtocolResource;
/// <inheritdoc />
public override ResourceTemplate ProtocolResourceTemplate => _innerResource.ProtocolResourceTemplate;
/// <inheritdoc />
public override ValueTask<ReadResourceResult?> ReadAsync(RequestContext<ReadResourceRequestParams> request, CancellationToken cancellationToken = default) =>
_innerResource.ReadAsync(request, cancellationToken);
/// <inheritdoc />
public override string ToString() => _innerResource.ToString();
}

View file

@ -5,6 +5,6 @@ namespace ModelContextProtocol.Server;
/// </summary>
public interface IMcpServerPrimitive
{
/// <summary>Gets the name of the primitive.</summary>
string Name { get; }
/// <summary>Gets the unique identifier of the primitive.</summary>
string Id { get; }
}

View file

@ -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<Action> _disposables = [];
private string _endpointName;
private int _started;
@ -60,13 +58,15 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
_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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
// 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();
/// <inheritdoc />
public ClientCapabilities? ClientCapabilities { get; set; }
@ -140,22 +142,11 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
McpJsonUtilities.JsonContext.Default.PingResult);
}
private void SetInitializeHandler(McpServerOptions options)
private void ConfigureInitialize(McpServerOptions options)
{
RequestHandlers.Set(RequestMethods.Initialize,
async (request, _, _) =>
@ -187,45 +178,126 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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();
if ((listResourcesHandler is not { } && listResourceTemplatesHandler is not { }) ||
resourcesCapability.ReadResourceHandler is not { } readResourceHandler)
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;
// 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<Resource>());
}
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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
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) =>

View file

@ -29,7 +29,7 @@ public static class McpServerExtensions
/// It allows detailed control over sampling parameters including messages, system prompt, temperature,
/// and token limits.
/// </remarks>
public static Task<CreateMessageResult> RequestSamplingAsync(
public static ValueTask<CreateMessageResult> RequestSamplingAsync(
this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken)
{
Throw.IfNull(server);
@ -197,7 +197,7 @@ public static class McpServerExtensions
/// 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.
/// </remarks>
public static Task<ListRootsResult> RequestRootsAsync(
public static ValueTask<ListRootsResult> RequestRootsAsync(
this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken)
{
Throw.IfNull(server);

View file

@ -65,7 +65,7 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
{
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 class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
{
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 class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
{
Throw.IfNull(primitive);
bool removed = ((ICollection<KeyValuePair<string, T>>)_primitives).Remove(new(primitive.Name, primitive));
bool removed = ((ICollection<KeyValuePair<string, T>>)_primitives).Remove(new(primitive.Id, primitive));
if (removed)
{
RaiseChanged();
@ -125,7 +125,7 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
public virtual bool Contains(T primitive)
{
Throw.IfNull(primitive);
return ((ICollection<KeyValuePair<string, T>>)_primitives).Contains(new(primitive.Name, primitive));
return ((ICollection<KeyValuePair<string, T>>)_primitives).Contains(new(primitive.Id, primitive));
}
/// <summary>Gets the names of all of the primitives in the collection.</summary>

View file

@ -217,5 +217,5 @@ public abstract class McpServerPrompt : IMcpServerPrimitive
public override string ToString() => ProtocolPrompt.Name;
/// <inheritdoc />
string IMcpServerPrimitive.Name => ProtocolPrompt.Name;
string IMcpServerPrimitive.Id => ProtocolPrompt.Name;
}

View file

@ -97,7 +97,7 @@ namespace ModelContextProtocol.Server;
/// <description>Converted to a list of <see cref="PromptMessage"/> instances derived from the <see cref="ChatMessage"/> with <see cref="AIContentExtensions.ToPromptMessages"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{PromptMessage}"/> of <see cref="PromptMessage"/></term>
/// <term><see cref="IEnumerable{ChatMessage}"/> of <see cref="ChatMessage"/></term>
/// <description>Converted to a list of <see cref="PromptMessage"/> instances derived from all of the <see cref="ChatMessage"/> instances with <see cref="AIContentExtensions.ToPromptMessages"/>.</description>
/// </item>
/// </list>

View file

@ -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;
/// <summary>
/// Represents an invocable resource used by Model Context Protocol clients and servers.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="McpServerResource"/> is an abstract base class that represents an MCP resource for use in the server (as opposed
/// to <see cref="Resource"/> or <see cref="ResourceTemplate"/>, which provide the protocol representations of a resource). Instances of
/// <see cref="McpServerResource"/> can be added into a <see cref="IServiceCollection"/> to be picked up automatically when
/// <see cref="McpServerFactory"/> is used to create an <see cref="IMcpServer"/>, or added into a <see cref="McpServerPrimitiveCollection{McpServerResource}"/>.
/// </para>
/// <para>
/// Most commonly, <see cref="McpServerResource"/> instances are created using the static <see cref="M:McpServerResource.Create"/> methods.
/// These methods enable creating an <see cref="McpServerResource"/> for a method, specified via a <see cref="Delegate"/> or
/// <see cref="MethodInfo"/>, and are what are used implicitly by <see cref="McpServerBuilderExtensions.WithResourcesFromAssembly"/> and
/// <see cref="M:McpServerBuilderExtensions.WithResources"/>. The <see cref="M:McpServerResource.Create"/> methods
/// create <see cref="McpServerResource"/> 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 <see cref="ReadResourceResult"/> that's then serialized and sent back to the client.
/// </para>
/// <para>
/// <see cref="McpServerResource"/> is used to represent both direct resources (e.g. "resource://example") and templated
/// resources (e.g. "resource://example/{id}").
/// </para>
/// <para>
/// 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.
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="CancellationToken"/> parameters are automatically bound to a <see cref="CancellationToken"/> provided by the
/// <see cref="IMcpServer"/> and that respects any <see cref="CancelledNotification"/>s sent by the client for this operation's
/// <see cref="RequestId"/>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="IServiceProvider"/> parameters are bound from the <see cref="RequestContext{ReadResourceRequestParams}"/> for this request.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="IMcpServer"/> parameters are bound directly to the <see cref="IMcpServer"/> instance associated
/// with this request's <see cref="RequestContext{ReadResourceRequestParams}"/>. 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.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="IProgress{ProgressNotificationValue}"/> parameters accepting <see cref="ProgressNotificationValue"/> values
/// are bound to an <see cref="IProgress{ProgressNotificationValue}"/> instance manufactured to forward progress notifications
/// from the resource to the client. If the client included a <see cref="ProgressToken"/> in their request, progress reports issued
/// to this instance will propagate to the client as <see cref="NotificationMethods.ProgressNotification"/> notifications with
/// that token. If the client did not include a <see cref="ProgressToken"/>, the instance will ignore any progress reports issued to it.
/// </description>
/// </item>
/// <item>
/// <description>
/// When the <see cref="McpServerResource"/> is constructed, it may be passed an <see cref="IServiceProvider"/> via
/// <see cref="McpServerResourceCreateOptions.Services"/>. Any parameter that can be satisfied by that <see cref="IServiceProvider"/>
/// according to <see cref="IServiceProviderIsService"/> will be resolved from the <see cref="IServiceProvider"/> provided to the
/// resource invocation rather than from the argument collection.
/// </description>
/// </item>
/// <item>
/// <description>
/// Any parameter attributed with <see cref="FromKeyedServicesAttribute"/> will similarly be resolved from the
/// <see cref="IServiceProvider"/> provided to the resource invocation rather than from the argument collection.
/// </description>
/// </item>
/// <item>
/// <description>
/// All other parameters are bound from the data in the URI.
/// </description>
/// </item>
/// </list>
/// </para>
/// <para>
/// Return values from a method are used to create the <see cref="ReadResourceResult"/> that is sent back to the client:
/// </para>
/// <list type="table">
/// <item>
/// <term><see cref="ResourceContents"/></term>
/// <description>Wrapped in a list containing the single <see cref="ResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="TextContent"/></term>
/// <description>Converted to a list containing a single <see cref="TextResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="DataContent"/></term>
/// <description>Converted to a list containing a single <see cref="BlobResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="string"/></term>
/// <description>Converted to a list containing a single <see cref="TextResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{ResourceContents}"/> of <see cref="ResourceContents"/></term>
/// <description>Returned directly as a list of <see cref="ResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{AIContent}"/> of <see cref="AIContent"/></term>
/// <description>Converted to a list containing a <see cref="TextResourceContents"/> for each <see cref="TextContent"/> and a <see cref="BlobResourceContents"/> for each <see cref="DataContent"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{String}"/> of <see cref="string"/></term>
/// <description>Converted to a list containing a <see cref="TextResourceContents"/>, one for each <see cref="string"/>.</description>
/// </item>
/// </list>
/// <para>
/// Other returned types will result in an <see cref="InvalidOperationException"/> being thrown.
/// </para>
/// </remarks>
public abstract class McpServerResource : IMcpServerPrimitive
{
/// <summary>Initializes a new instance of the <see cref="McpServerResource"/> class.</summary>
protected McpServerResource()
{
}
/// <summary>Gets whether this resource is a URI template with parameters as opposed to a direct resource.</summary>
public bool IsTemplated => ProtocolResourceTemplate.UriTemplate.Contains('{');
/// <summary>Gets the protocol <see cref="ResourceTemplate"/> type for this instance.</summary>
/// <remarks>
/// <para>
/// The <see cref="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.
/// </para>
/// <para>
/// Every valid resource URI is a valid resource URI template, and thus this property always returns an instance.
/// In contrast, the <see cref="ProtocolResource"/> property may return <see langword="null"/> if the resource template
/// contains a parameter, in which case the resource template URI is not a valid resource URI.
/// </para>
/// </remarks>
public abstract ResourceTemplate ProtocolResourceTemplate { get; }
/// <summary>Gets the protocol <see cref="Resource"/> type for this instance.</summary>
/// <remarks>
/// 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.
/// </remarks>
public virtual Resource? ProtocolResource => ProtocolResourceTemplate.AsResource();
/// <summary>
/// Gets the resource, rendering it with the provided request parameters and returning the resource result.
/// </summary>
/// <param name="request">
/// 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.
/// </param>
/// <param name="cancellationToken">
/// The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.
/// </param>
/// <returns>
/// A <see cref="ValueTask{ReadResourceResult}"/> representing the asynchronous operation, containing a <see cref="ReadResourceResult"/> with
/// the resource content and messages. If and only if this <see cref="McpServerResource"/> doesn't match the <see cref="ReadResourceRequestParams.Uri"/>,
/// the method returns <see langword="null"/>.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The resource implementation returned <see langword="null"/> or an unsupported result type.</exception>
public abstract ValueTask<ReadResourceResult?> ReadAsync(
RequestContext<ReadResourceRequestParams> request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates an <see cref="McpServerResource"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="McpServerResource"/>.</param>
/// <param name="options">Optional options used in the creation of the <see cref="McpServerResource"/> to control its behavior.</param>
/// <returns>The created <see cref="McpServerResource"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static McpServerResource Create(
Delegate method,
McpServerResourceCreateOptions? options = null) =>
AIFunctionMcpServerResource.Create(method, options);
/// <summary>
/// Creates an <see cref="McpServerResource"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="McpServerResource"/>.</param>
/// <param name="target">The instance if <paramref name="method"/> is an instance method; otherwise, <see langword="null"/>.</param>
/// <param name="options">Optional options used in the creation of the <see cref="McpServerResource"/> to control its behavior.</param>
/// <returns>The created <see cref="McpServerResource"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="method"/> is an instance method but <paramref name="target"/> is <see langword="null"/>.</exception>
public static McpServerResource Create(
MethodInfo method,
object? target = null,
McpServerResourceCreateOptions? options = null) =>
AIFunctionMcpServerResource.Create(method, target, options);
/// <summary>
/// Creates an <see cref="McpServerResource"/> instance for a method, specified via an <see cref="MethodInfo"/> for
/// and instance method, along with a <see cref="Type"/> representing the type of the target object to
/// instantiate each time the method is invoked.
/// </summary>
/// <param name="method">The instance method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="targetType">
/// The <see cref="Type"/> to construct an instance of on which to invoke <paramref name="method"/> when
/// the resulting <see cref="AIFunction"/> is invoked. If services are provided,
/// ActivatorUtilities.CreateInstance will be used to construct the instance using those services; otherwise,
/// <see cref="Activator.CreateInstance(Type)"/> 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.
/// </param>
/// <param name="options">Optional options used in the creation of the <see cref="McpServerResource"/> to control its behavior.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static McpServerResource Create(
MethodInfo method,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
McpServerResourceCreateOptions? options = null) =>
AIFunctionMcpServerResource.Create(method, targetType, options);
/// <summary>Creates an <see cref="McpServerResource"/> that wraps the specified <see cref="AIFunction"/>.</summary>
/// <param name="function">The function to wrap.</param>
/// <param name="options">Optional options used in the creation of the <see cref="McpServerResource"/> to control its behavior.</param>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Unlike the other overloads of Create, the <see cref="McpServerResource"/> created by <see cref="Create(AIFunction, McpServerResourceCreateOptions)"/>
/// does not provide all of the special parameter handling for MCP-specific concepts, like <see cref="IMcpServer"/>.
/// </remarks>
public static McpServerResource Create(
AIFunction function,
McpServerResourceCreateOptions? options = null) =>
AIFunctionMcpServerResource.Create(function, options);
/// <inheritdoc />
public override string ToString() => ProtocolResourceTemplate.UriTemplate;
/// <inheritdoc />
string IMcpServerPrimitive.Id => ProtocolResourceTemplate.UriTemplate;
}

View file

@ -0,0 +1,136 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
namespace ModelContextProtocol.Server;
/// <summary>
/// Used to indicate that a method or property should be considered an <see cref="McpServerResource"/>.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="McpServerBuilderExtensions"/>,
/// these methods or properties become available as resources that can be called by MCP clients.
/// </para>
/// <para>
/// When methods are provided directly to <see cref="M:McpServerResource.Create"/>, the attribute is not required.
/// </para>
/// <para>
/// 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.
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="CancellationToken"/> parameters are automatically bound to a <see cref="CancellationToken"/> provided by the
/// <see cref="IMcpServer"/> and that respects any <see cref="CancelledNotification"/>s sent by the client for this operation's
/// <see cref="RequestId"/>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="IServiceProvider"/> parameters are bound from the <see cref="RequestContext{ReadResourceRequestParams}"/> for this request.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="IMcpServer"/> parameters are bound directly to the <see cref="IMcpServer"/> instance associated
/// with this request's <see cref="RequestContext{ReadResourceRequestParams}"/>. 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.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="IProgress{ProgressNotificationValue}"/> parameters accepting <see cref="ProgressNotificationValue"/> values
/// are bound to an <see cref="IProgress{ProgressNotificationValue}"/> instance manufactured to forward progress notifications
/// from the resource to the client. If the client included a <see cref="ProgressToken"/> in their request, progress reports issued
/// to this instance will propagate to the client as <see cref="NotificationMethods.ProgressNotification"/> notifications with
/// that token. If the client did not include a <see cref="ProgressToken"/>, the instance will ignore any progress reports issued to it.
/// </description>
/// </item>
/// <item>
/// <description>
/// When the <see cref="McpServerResource"/> is constructed, it may be passed an <see cref="IServiceProvider"/> via
/// <see cref="McpServerResourceCreateOptions.Services"/>. Any parameter that can be satisfied by that <see cref="IServiceProvider"/>
/// according to <see cref="IServiceProviderIsService"/> will be resolved from the <see cref="IServiceProvider"/> provided to the
/// resource invocation rather than from the argument collection.
/// </description>
/// </item>
/// <item>
/// <description>
/// Any parameter attributed with <see cref="FromKeyedServicesAttribute"/> will similarly be resolved from the
/// <see cref="IServiceProvider"/> provided to the resource invocation rather than from the argument collection.
/// </description>
/// </item>
/// <item>
/// <description>
/// All other parameters are bound from the data in the URI.
/// </description>
/// </item>
/// </list>
/// </para>
/// <para>
/// Return values from a method are used to create the <see cref="ReadResourceResult"/> that is sent back to the client:
/// </para>
/// <list type="table">
/// <item>
/// <term><see cref="ResourceContents"/></term>
/// <description>Wrapped in a list containing the single <see cref="ResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="TextContent"/></term>
/// <description>Converted to a list containing a single <see cref="TextResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="DataContent"/></term>
/// <description>Converted to a list containing a single <see cref="BlobResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="string"/></term>
/// <description>Converted to a list containing a single <see cref="TextResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{ResourceContents}"/> of <see cref="ResourceContents"/></term>
/// <description>Returned directly as a list of <see cref="ResourceContents"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{AIContent}"/> of <see cref="AIContent"/></term>
/// <description>Converted to a list containing a <see cref="TextResourceContents"/> for each <see cref="TextContent"/> and a <see cref="BlobResourceContents"/> for each <see cref="DataContent"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{String}"/> of <see cref="string"/></term>
/// <description>Converted to a list containing a <see cref="TextResourceContents"/>, one for each <see cref="string"/>.</description>
/// </item>
/// </list>
/// <para>
/// Other returned types will result in an <see cref="InvalidOperationException"/> being thrown.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Method)]
public sealed class McpServerResourceAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="McpServerResourceAttribute"/> class.
/// </summary>
public McpServerResourceAttribute()
{
}
/// <summary>Gets or sets the URI template of the resource.</summary>
/// <remarks>
/// If <see langword="null"/>, a URI will be derived from <see cref="Name"/> and the method's parameter names.
/// This template may, but doesn't have to, include parameters; if it does, this <see cref="McpServerResource"/>
/// will be considered a "resource template", and if it doesn't, it will be considered a "direct resource".
/// The former will be listed with <see cref="RequestMethods.ResourcesTemplatesList"/> requests and the latter
/// with <see cref="RequestMethods.ResourcesList"/> requests.
/// </remarks>
public string? UriTemplate { get; set; }
/// <summary>Gets or sets the name of the resource.</summary>
/// <remarks>If <see langword="null"/>, the method name will be used.</remarks>
public string? Name { get; set; }
/// <summary>Gets or sets the MIME (media) type of the resource.</summary>
public string? MimeType { get; set; }
}

View file

@ -0,0 +1,75 @@
using System.ComponentModel;
namespace ModelContextProtocol.Server;
/// <summary>
/// Provides options for controlling the creation of an <see cref="McpServerResource"/>.
/// </summary>
/// <remarks>
/// <para>
/// These options allow for customizing the behavior and metadata of resources created with
/// <see cref="M:McpServerResource.Create"/>. They provide control over naming, description,
/// and dependency injection integration.
/// </para>
/// <para>
/// When creating resources programmatically rather than using attributes, these options
/// provide the same level of configuration flexibility.
/// </para>
/// </remarks>
public sealed class McpServerResourceCreateOptions
{
/// <summary>
/// Gets or sets optional services used in the construction of the <see cref="McpServerResource"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public IServiceProvider? Services { get; set; }
/// <summary>
/// Gets or sets the URI template of the <see cref="McpServerResource"/>.
/// </summary>
/// <remarks>
/// If <see langword="null"/>, but an <see cref="McpServerResourceAttribute"/> is applied to the member,
/// the <see cref="McpServerResourceAttribute.UriTemplate"/> from the attribute will be used. If that's not present,
/// a URI template will be inferred from the member's signature.
/// </remarks>
public string? UriTemplate { get; set; }
/// <summary>
/// Gets or sets the name to use for the <see cref="McpServerResource"/>.
/// </summary>
/// <remarks>
/// If <see langword="null"/>, but an <see cref="McpServerResourceAttribute"/> 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.
/// </remarks>
public string? Name { get; set; }
/// <summary>
/// Gets or set the description to use for the <see cref="McpServerResource"/>.
/// </summary>
/// <remarks>
/// If <see langword="null"/>, but a <see cref="DescriptionAttribute"/> is applied to the member,
/// the description from that attribute will be used.
/// </remarks>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the MIME (media) type of the <see cref="McpServerResource"/>.
/// </summary>
public string? MimeType { get; set; }
/// <summary>
/// Creates a shallow clone of the current <see cref="McpServerResourceCreateOptions"/> instance.
/// </summary>
internal McpServerResourceCreateOptions Clone() =>
new McpServerResourceCreateOptions
{
Services = Services,
UriTemplate = UriTemplate,
Name = Name,
Description = Description,
MimeType = MimeType,
};
}

View file

@ -0,0 +1,22 @@
using Microsoft.Extensions.DependencyInjection;
namespace ModelContextProtocol.Server;
/// <summary>
/// Used to attribute a type containing members that should be exposed as <see cref="McpServerResource"/>s.
/// </summary>
/// <remarks>
/// <para>
/// This attribute is used to mark a class containing members that should be automatically
/// discovered and registered as <see cref="McpServerResource"/>s. When combined with discovery methods like
/// <see cref="McpServerBuilderExtensions.WithResourcesFromAssembly"/>, 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 <see cref="McpServerBuilderExtensions.WithResources{T}"/>.
/// </para>
/// <para>
/// Within a class marked with this attribute, individual members that should be exposed as
/// resources must be marked with the <see cref="McpServerResourceAttribute"/>.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Class)]
public sealed class McpServerResourceTypeAttribute : Attribute;

View file

@ -220,5 +220,5 @@ public abstract class McpServerTool : IMcpServerPrimitive
public override string ToString() => ProtocolTool.Name;
/// <inheritdoc />
string IMcpServerPrimitive.Name => ProtocolTool.Name;
string IMcpServerPrimitive.Id => ProtocolTool.Name;
}