813 lines
37 KiB
C#
813 lines
37 KiB
C#
using Microsoft.Extensions.AI;
|
|
using Microsoft.Extensions.Logging;
|
|
using ModelContextProtocol.Protocol;
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.Json.Serialization.Metadata;
|
|
|
|
namespace ModelContextProtocol.Server;
|
|
|
|
/// <summary>
|
|
/// Represents an instance of a Model Context Protocol (MCP) server that connects to and communicates with an MCP client.
|
|
/// </summary>
|
|
public abstract partial class McpServer : McpSession
|
|
{
|
|
/// <summary>
|
|
/// Caches request schemas for elicitation requests based on the type and serializer options.
|
|
/// </summary>
|
|
private static readonly ConditionalWeakTable<JsonSerializerOptions, ConcurrentDictionary<Type, ElicitRequestParams.RequestSchema>> s_elicitResultSchemaCache = new();
|
|
|
|
private static Dictionary<string, HashSet<string>>? s_elicitAllowedProperties = null;
|
|
|
|
/// <summary>
|
|
/// Creates a new instance of an <see cref="McpServer"/>.
|
|
/// </summary>
|
|
/// <param name="transport">The transport to use for the server representing an already-established MCP session.</param>
|
|
/// <param name="serverOptions">Configuration options for this server, including capabilities. </param>
|
|
/// <param name="loggerFactory">Logger factory to use for logging. If null, logging will be disabled.</param>
|
|
/// <param name="serviceProvider">Optional service provider to create new instances of tools and other dependencies.</param>
|
|
/// <returns>An <see cref="McpServer"/> instance that should be disposed when no longer needed.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="transport"/> or <paramref name="serverOptions"/> is <see langword="null"/>.</exception>
|
|
public static McpServer Create(
|
|
ITransport transport,
|
|
McpServerOptions serverOptions,
|
|
ILoggerFactory? loggerFactory = null,
|
|
IServiceProvider? serviceProvider = null)
|
|
{
|
|
Throw.IfNull(transport);
|
|
Throw.IfNull(serverOptions);
|
|
|
|
return new McpServerImpl(transport, serverOptions, loggerFactory, serviceProvider);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Requests to sample an LLM via the client using the specified request parameters.
|
|
/// </summary>
|
|
/// <param name="requestParams">The parameters for the sampling request.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
|
/// <returns>A task containing the sampling result from the client.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
|
|
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// When the server is using the Streamable HTTP transport, prefer calling this method on the
|
|
/// <see cref="McpServer"/> instance available via <c>RequestContext</c> from inside a tool, prompt,
|
|
/// or resource handler. That routes the request through the originating POST response stream via
|
|
/// <see cref="JsonRpcMessageContext.RelatedTransport"/>, which is always open for the duration of
|
|
/// the request, rather than relying on the optional standalone GET SSE stream.
|
|
/// </para>
|
|
/// <para>
|
|
/// When called during task-augmented tool execution, this method automatically updates the task
|
|
/// status to <see cref="McpTaskStatus.InputRequired"/> while waiting for the client response,
|
|
/// then returns to <see cref="McpTaskStatus.Working"/> when the response is received.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
|
public ValueTask<CreateMessageResult> SampleAsync(
|
|
CreateMessageRequestParams requestParams,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNull(requestParams);
|
|
|
|
// If executing inside a background task, redirect sampling through the task store.
|
|
// Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the
|
|
// client opted into the tasks extension when submitting the originating request, and input
|
|
// requests are delivered through the tasks/get response channel rather than as direct
|
|
// server->client requests. See SendRequestViaTaskAsync remarks.
|
|
if (McpTaskExecutionContext.Current.Value is { } taskContext)
|
|
{
|
|
return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams,
|
|
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
|
|
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
|
|
cancellationToken);
|
|
}
|
|
|
|
ThrowIfSamplingUnsupported();
|
|
|
|
return SendRequestAsync(
|
|
RequestMethods.SamplingCreateMessage,
|
|
requestParams,
|
|
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
|
|
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
|
|
cancellationToken: cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Requests to sample an LLM via the client using the provided chat messages and options.
|
|
/// </summary>
|
|
/// <param name="messages">The messages to send as part of the request.</param>
|
|
/// <param name="chatOptions">The options to use for the request, including model parameters and constraints.</param>
|
|
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use for serializing user-provided objects. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
/// <returns>A task containing the chat response from the model.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
|
|
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
|
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
|
public async Task<ChatResponse> SampleAsync(
|
|
IEnumerable<ChatMessage> messages, ChatOptions? chatOptions = default, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNull(messages);
|
|
|
|
serializerOptions ??= McpJsonUtilities.DefaultOptions;
|
|
|
|
StringBuilder? systemPrompt = null;
|
|
|
|
if (chatOptions?.Instructions is { } instructions)
|
|
{
|
|
(systemPrompt ??= new()).Append(instructions);
|
|
}
|
|
|
|
List<SamplingMessage> samplingMessages = [];
|
|
foreach (var message in messages)
|
|
{
|
|
if (message.Role == ChatRole.System)
|
|
{
|
|
if (systemPrompt is null)
|
|
{
|
|
systemPrompt = new();
|
|
}
|
|
else
|
|
{
|
|
systemPrompt.AppendLine();
|
|
}
|
|
|
|
systemPrompt.Append(message.Text);
|
|
continue;
|
|
}
|
|
|
|
Role role = message.Role == ChatRole.Assistant ? Role.Assistant : Role.User;
|
|
|
|
// Group all content blocks from this message into a single SamplingMessage
|
|
List<ContentBlock> contentBlocks = [];
|
|
foreach (var content in message.Contents)
|
|
{
|
|
if (content.ToContentBlock() is { } contentBlock)
|
|
{
|
|
contentBlocks.Add(contentBlock);
|
|
}
|
|
}
|
|
|
|
if (contentBlocks.Count > 0)
|
|
{
|
|
samplingMessages.Add(new()
|
|
{
|
|
Role = role,
|
|
Content = contentBlocks,
|
|
});
|
|
}
|
|
}
|
|
|
|
ModelPreferences? modelPreferences = null;
|
|
if (chatOptions?.ModelId is { } modelId)
|
|
{
|
|
modelPreferences = new() { Hints = [new() { Name = modelId }] };
|
|
}
|
|
|
|
IList<Tool>? tools = null;
|
|
if (chatOptions?.Tools is { Count: > 0 })
|
|
{
|
|
foreach (var tool in chatOptions.Tools)
|
|
{
|
|
if (tool is AIFunctionDeclaration af)
|
|
{
|
|
(tools ??= []).Add(new()
|
|
{
|
|
Name = af.Name,
|
|
Description = af.Description,
|
|
InputSchema = af.JsonSchema,
|
|
Meta = af.AdditionalProperties.ToJsonObject(serializerOptions),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolChoice? toolChoice = chatOptions?.ToolMode switch
|
|
{
|
|
NoneChatToolMode => new() { Mode = ToolChoice.ModeNone },
|
|
AutoChatToolMode => new() { Mode = ToolChoice.ModeAuto },
|
|
RequiredChatToolMode => new() { Mode = ToolChoice.ModeRequired },
|
|
_ => null,
|
|
};
|
|
|
|
var result = await SampleAsync(new CreateMessageRequestParams
|
|
{
|
|
MaxTokens = chatOptions?.MaxOutputTokens ?? ServerOptions.MaxSamplingOutputTokens,
|
|
Messages = samplingMessages,
|
|
ModelPreferences = modelPreferences,
|
|
StopSequences = chatOptions?.StopSequences?.ToArray(),
|
|
SystemPrompt = systemPrompt?.ToString(),
|
|
Temperature = chatOptions?.Temperature,
|
|
ToolChoice = toolChoice,
|
|
Tools = tools,
|
|
Meta = chatOptions?.AdditionalProperties?.ToJsonObject(serializerOptions),
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
List<AIContent> responseContents = [];
|
|
foreach (var block in result.Content)
|
|
{
|
|
if (block.ToAIContent(serializerOptions) is { } content)
|
|
{
|
|
responseContents.Add(content);
|
|
}
|
|
}
|
|
|
|
return new(new ChatMessage(result.Role is Role.User ? ChatRole.User : ChatRole.Assistant, responseContents))
|
|
{
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
FinishReason = result.StopReason switch
|
|
{
|
|
CreateMessageResult.StopReasonEndTurn => ChatFinishReason.Stop,
|
|
CreateMessageResult.StopReasonMaxTokens => ChatFinishReason.Length,
|
|
CreateMessageResult.StopReasonStopSequence => ChatFinishReason.Stop,
|
|
CreateMessageResult.StopReasonToolUse => ChatFinishReason.ToolCalls,
|
|
_ => null,
|
|
},
|
|
ModelId = result.Model,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an <see cref="IChatClient"/> wrapper that can be used to send sampling requests to the client.
|
|
/// </summary>
|
|
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use for serialization. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
|
|
/// <returns>The <see cref="IChatClient"/> that can be used to issue sampling requests to the client.</returns>
|
|
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
|
|
/// <remarks>
|
|
/// When the server is using the Streamable HTTP transport, prefer obtaining this chat client from the
|
|
/// <see cref="McpServer"/> instance available via <c>RequestContext</c> from inside a tool, prompt,
|
|
/// or resource handler. That routes sampling requests through the originating POST response stream via
|
|
/// <see cref="JsonRpcMessageContext.RelatedTransport"/>, which is always open for the duration of
|
|
/// the request, rather than relying on the optional standalone GET SSE stream.
|
|
/// </remarks>
|
|
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
|
public IChatClient AsSamplingChatClient(JsonSerializerOptions? serializerOptions = null)
|
|
{
|
|
ThrowIfSamplingUnsupported();
|
|
|
|
return new SamplingChatClient(this, serializerOptions ?? McpJsonUtilities.DefaultOptions);
|
|
}
|
|
|
|
/// <summary>Gets an <see cref="ILogger"/> on which logged messages will be sent as notifications to the client.</summary>
|
|
/// <returns>An <see cref="ILogger"/> that can be used to log to the client.</returns>
|
|
[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
|
public ILoggerProvider AsClientLoggerProvider() =>
|
|
new ClientLoggerProvider(this);
|
|
|
|
/// <summary>
|
|
/// Requests the client to list the roots it exposes.
|
|
/// </summary>
|
|
/// <param name="requestParams">The parameters for the list roots request.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
|
/// <returns>A task containing the list of roots exposed by the client.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="InvalidOperationException">The client does not support roots.</exception>
|
|
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
|
/// <remarks>
|
|
/// When the server is using the Streamable HTTP transport, prefer calling this method on the
|
|
/// <see cref="McpServer"/> instance available via <c>RequestContext</c> from inside a tool, prompt,
|
|
/// or resource handler. That routes the request through the originating POST response stream via
|
|
/// <see cref="JsonRpcMessageContext.RelatedTransport"/>, which is always open for the duration of
|
|
/// the request, rather than relying on the optional standalone GET SSE stream.
|
|
/// </remarks>
|
|
[Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
|
public ValueTask<ListRootsResult> RequestRootsAsync(
|
|
ListRootsRequestParams requestParams,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNull(requestParams);
|
|
|
|
// If executing inside a background task, redirect through the task store.
|
|
// Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the
|
|
// client opted into the tasks extension when submitting the originating request, and input
|
|
// requests are delivered through the tasks/get response channel rather than as direct
|
|
// server->client requests. See SendRequestViaTaskAsync remarks.
|
|
if (McpTaskExecutionContext.Current.Value is { } taskContext)
|
|
{
|
|
return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams,
|
|
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
|
|
McpJsonUtilities.JsonContext.Default.ListRootsResult,
|
|
cancellationToken);
|
|
}
|
|
|
|
ThrowIfRootsUnsupported();
|
|
|
|
return SendRequestAsync(
|
|
RequestMethods.RootsList,
|
|
requestParams,
|
|
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
|
|
McpJsonUtilities.JsonContext.Default.ListRootsResult,
|
|
cancellationToken: cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Requests additional information from the user via the client, allowing the server to elicit structured data.
|
|
/// </summary>
|
|
/// <param name="requestParams">The parameters for the elicitation request.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
|
/// <returns>A task containing the elicitation result.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="InvalidOperationException">The client does not support elicitation.</exception>
|
|
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// When the server is using the Streamable HTTP transport, prefer calling this method on the
|
|
/// <see cref="McpServer"/> instance available via <c>RequestContext</c> from inside a tool, prompt,
|
|
/// or resource handler. That routes the request through the originating POST response stream via
|
|
/// <see cref="JsonRpcMessageContext.RelatedTransport"/>, which is always open for the duration of
|
|
/// the request, rather than relying on the optional standalone GET SSE stream.
|
|
/// </para>
|
|
/// <para>
|
|
/// When called during task-augmented tool execution, this method automatically updates the task
|
|
/// status to <see cref="McpTaskStatus.InputRequired"/> while waiting for user input,
|
|
/// then returns to <see cref="McpTaskStatus.Working"/> when the response is received.
|
|
/// </para>
|
|
/// </remarks>
|
|
public async ValueTask<ElicitResult> ElicitAsync(
|
|
ElicitRequestParams requestParams,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNull(requestParams);
|
|
|
|
// If executing inside a background task, redirect elicitation through the task store.
|
|
// Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because
|
|
// the client opted into the tasks extension when submitting the originating request, and
|
|
// input requests are delivered through the tasks/get response channel rather than as
|
|
// direct server->client requests. See SendRequestViaTaskAsync remarks.
|
|
if (McpTaskExecutionContext.Current.Value is { } taskContext)
|
|
{
|
|
var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams,
|
|
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
|
McpJsonUtilities.JsonContext.Default.ElicitResult,
|
|
cancellationToken).ConfigureAwait(false);
|
|
return taskResult ?? new ElicitResult { Action = "cancel" };
|
|
}
|
|
|
|
ThrowIfElicitationUnsupported(requestParams);
|
|
|
|
var result = await SendRequestAsync(
|
|
RequestMethods.ElicitationCreate,
|
|
requestParams,
|
|
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
|
McpJsonUtilities.JsonContext.Default.ElicitResult,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
|
|
return ElicitResult.WithDefaults(requestParams, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Requests additional information from the user via the client, constructing a request schema from the
|
|
/// public serializable properties of <typeparamref name="T"/> and deserializing the response into <typeparamref name="T"/>.
|
|
/// </summary>
|
|
/// <typeparam name="T">The type describing the expected input shape. Only primitive members are supported (string, number, boolean, enum).</typeparam>
|
|
/// <param name="message">The message to present to the user.</param>
|
|
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
|
/// <returns>An <see cref="ElicitResult{T}"/> with the user's response, if accepted.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="ArgumentException"><paramref name="message"/> is empty or composed entirely of whitespace.</exception>
|
|
/// <exception cref="InvalidOperationException">The client does not support elicitation.</exception>
|
|
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
|
/// <remarks>
|
|
/// Elicitation uses a constrained subset of JSON Schema and only supports strings, numbers/integers, booleans and string enums.
|
|
/// Unsupported member types are ignored when constructing the schema.
|
|
/// </remarks>
|
|
public async ValueTask<ElicitResult<T>> ElicitAsync<T>(
|
|
string message,
|
|
RequestOptions? options = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNullOrWhiteSpace(message);
|
|
|
|
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
|
|
serializerOptions.MakeReadOnly();
|
|
|
|
var dict = s_elicitResultSchemaCache.GetValue(serializerOptions, _ => new());
|
|
|
|
var schema = dict.GetOrAdd(typeof(T),
|
|
#if NET
|
|
static (t, s) => BuildRequestSchema(t, s), serializerOptions);
|
|
#else
|
|
type => BuildRequestSchema(type, serializerOptions));
|
|
#endif
|
|
|
|
var request = new ElicitRequestParams
|
|
{
|
|
Message = message,
|
|
RequestedSchema = schema,
|
|
Meta = options?.GetMetaForRequest(),
|
|
};
|
|
|
|
ThrowIfElicitationUnsupported(request);
|
|
|
|
var raw = await ElicitAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!raw.IsAccepted || raw.Content is null)
|
|
{
|
|
return new ElicitResult<T> { Action = raw.Action, Content = default };
|
|
}
|
|
|
|
JsonObject obj = [];
|
|
foreach (var kvp in raw.Content)
|
|
{
|
|
obj[kvp.Key] = JsonNode.Parse(kvp.Value.GetRawText());
|
|
}
|
|
|
|
T? typed = JsonSerializer.Deserialize(obj, serializerOptions.GetTypeInfo<T>());
|
|
return new ElicitResult<T> { Action = raw.Action, Content = typed };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a task status notification to the connected client.
|
|
/// </summary>
|
|
/// <param name="notificationParams">The task status notification parameters to send.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
/// <returns>A task that represents the asynchronous send operation.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="notificationParams"/> is <see langword="null"/>.</exception>
|
|
public Task SendTaskStatusNotificationAsync(
|
|
TaskStatusNotificationParams notificationParams,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNull(notificationParams);
|
|
|
|
return SendNotificationAsync(
|
|
NotificationMethods.TaskStatusNotification,
|
|
notificationParams,
|
|
McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a request schema for elicitation based on the public serializable properties of <paramref name="type"/>.
|
|
/// </summary>
|
|
/// <param name="type">The type of the schema being built.</param>
|
|
/// <param name="serializerOptions">The serializer options to use.</param>
|
|
/// <returns>The built request schema.</returns>
|
|
/// <exception cref="McpProtocolException"></exception>
|
|
private static ElicitRequestParams.RequestSchema BuildRequestSchema(Type type, JsonSerializerOptions serializerOptions)
|
|
{
|
|
var schema = new ElicitRequestParams.RequestSchema();
|
|
var props = schema.Properties;
|
|
|
|
JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(type);
|
|
|
|
if (typeInfo.Kind != JsonTypeInfoKind.Object)
|
|
{
|
|
throw new McpProtocolException($"Type '{type.FullName}' is not supported for elicitation requests.");
|
|
}
|
|
|
|
foreach (JsonPropertyInfo pi in typeInfo.Properties)
|
|
{
|
|
var def = CreatePrimitiveSchema(pi.PropertyType, serializerOptions);
|
|
props[pi.Name] = def;
|
|
}
|
|
|
|
return schema;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a primitive schema definition for the specified type, if supported.
|
|
/// </summary>
|
|
/// <param name="type">The type to create the schema for.</param>
|
|
/// <param name="serializerOptions">The serializer options to use.</param>
|
|
/// <returns>The created primitive schema definition.</returns>
|
|
/// <exception cref="McpProtocolException">The type is not supported.</exception>
|
|
private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions)
|
|
{
|
|
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
|
|
{
|
|
throw new McpProtocolException($"Type '{type.FullName}' is not a supported property type for elicitation requests. Nullable types are not supported.");
|
|
}
|
|
|
|
var typeInfo = serializerOptions.GetTypeInfo(type);
|
|
|
|
if (typeInfo.Kind != JsonTypeInfoKind.None)
|
|
{
|
|
throw new McpProtocolException($"Type '{type.FullName}' is not a supported property type for elicitation requests.");
|
|
}
|
|
|
|
var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions);
|
|
|
|
if (!TryValidateElicitationPrimitiveSchema(jsonElement, type, out var error))
|
|
{
|
|
throw new McpProtocolException(error);
|
|
}
|
|
|
|
return
|
|
jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition) ??
|
|
throw new McpProtocolException($"Type '{type.FullName}' is not a supported property type for elicitation requests.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate the produced schema strictly to the subset we support. We only accept an object schema
|
|
/// with a supported primitive type keyword and no additional unsupported keywords.Reject things like
|
|
/// {}, 'true', or schemas that include unrelated keywords(e.g.items, properties, patternProperties, etc.).
|
|
/// </summary>
|
|
/// <param name="schema">The schema to validate.</param>
|
|
/// <param name="type">The type of the schema being validated, just for reporting errors.</param>
|
|
/// <param name="error">The error message, if validation fails.</param>
|
|
/// <returns></returns>
|
|
private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Type type,
|
|
[NotNullWhen(false)] out string? error)
|
|
{
|
|
if (schema.ValueKind is not JsonValueKind.Object)
|
|
{
|
|
error = $"Schema generated for type '{type.FullName}' is invalid: expected an object schema.";
|
|
return false;
|
|
}
|
|
|
|
if (!schema.TryGetProperty("type", out JsonElement typeProperty)
|
|
|| typeProperty.ValueKind is not JsonValueKind.String)
|
|
{
|
|
error = $"Schema generated for type '{type.FullName}' is invalid: missing or invalid 'type' keyword.";
|
|
return false;
|
|
}
|
|
|
|
var typeKeyword = typeProperty.GetString();
|
|
|
|
if (string.IsNullOrEmpty(typeKeyword))
|
|
{
|
|
error = $"Schema generated for type '{type.FullName}' is invalid: empty 'type' value.";
|
|
return false;
|
|
}
|
|
|
|
if (typeKeyword is not ("string" or "number" or "integer" or "boolean"))
|
|
{
|
|
error = $"Schema generated for type '{type.FullName}' is invalid: unsupported primitive type '{typeKeyword}'.";
|
|
return false;
|
|
}
|
|
|
|
s_elicitAllowedProperties ??= new()
|
|
{
|
|
["string"] = ["type", "title", "description", "minLength", "maxLength", "format", "enum", "enumNames"],
|
|
["number"] = ["type", "title", "description", "minimum", "maximum"],
|
|
["integer"] = ["type", "title", "description", "minimum", "maximum"],
|
|
["boolean"] = ["type", "title", "description", "default"]
|
|
};
|
|
|
|
var allowed = s_elicitAllowedProperties[typeKeyword];
|
|
|
|
foreach (JsonProperty prop in schema.EnumerateObject())
|
|
{
|
|
if (!allowed.Contains(prop.Name))
|
|
{
|
|
error = $"The property '{type.FullName}.{prop.Name}' is not supported for elicitation.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private void ThrowIfSamplingUnsupported()
|
|
{
|
|
if (ClientCapabilities?.Sampling is null)
|
|
{
|
|
if (ClientCapabilities is null)
|
|
{
|
|
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
|
|
}
|
|
|
|
throw new InvalidOperationException("Client does not support sampling.");
|
|
}
|
|
}
|
|
|
|
private void ThrowIfRootsUnsupported()
|
|
{
|
|
if (ClientCapabilities?.Roots is null)
|
|
{
|
|
if (ClientCapabilities is null)
|
|
{
|
|
throw new InvalidOperationException("Roots are not supported in stateless mode.");
|
|
}
|
|
|
|
throw new InvalidOperationException("Client does not support roots.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through
|
|
/// the task store as input requests for the duration of the scope. Use this when executing tool logic
|
|
/// in the background as a task, so that any server-to-client requests are surfaced to the client via
|
|
/// the task's <see cref="McpTaskStatus.InputRequired"/> state instead of direct JSON-RPC messages.
|
|
/// </summary>
|
|
/// <param name="taskId">The task ID in the store.</param>
|
|
/// <param name="store">The task store to write input requests to.</param>
|
|
/// <returns>An <see cref="IDisposable"/> that restores the previous context when disposed.</returns>
|
|
public IDisposable CreateMcpTaskScope(
|
|
string taskId,
|
|
IMcpTaskStore store)
|
|
{
|
|
Throw.IfNull(taskId);
|
|
Throw.IfNull(store);
|
|
|
|
var previous = McpTaskExecutionContext.Current.Value;
|
|
McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext
|
|
{
|
|
TaskId = taskId,
|
|
Store = store,
|
|
};
|
|
return new McpTaskExecutionContext.Scope(previous);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a server-initiated request through the task store as an input request, then awaits the response.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// When executing inside a task scope, capability negotiation checks (such as
|
|
/// <see cref="ThrowIfSamplingUnsupported"/>, <see cref="ThrowIfRootsUnsupported"/>, and
|
|
/// <see cref="ThrowIfElicitationUnsupported"/>) are intentionally skipped by the callers
|
|
/// of this helper. The task channel itself is the negotiated capability: the client opted
|
|
/// in to the tasks extension when it submitted the originating request, and is responsible
|
|
/// for handling or rejecting the input requests surfaced through <c>tasks/get</c>.
|
|
/// </remarks>
|
|
private async ValueTask<TResponse> SendRequestViaTaskAsync<TRequest, TResponse>(
|
|
McpTaskExecutionContext taskContext,
|
|
string method,
|
|
TRequest request,
|
|
JsonTypeInfo<TRequest> requestTypeInfo,
|
|
JsonTypeInfo<TResponse> responseTypeInfo,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var requestId = Guid.NewGuid().ToString("N");
|
|
var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo);
|
|
|
|
var inputRequest = new InputRequest
|
|
{
|
|
Method = method,
|
|
Params = paramsJson,
|
|
};
|
|
|
|
var tcs = new TaskCompletionSource<InputResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
void handler(InputResponseReceivedEventArgs args)
|
|
{
|
|
if (args.TaskId == taskContext.TaskId && args.RequestId == requestId)
|
|
{
|
|
tcs.TrySetResult(args.Response);
|
|
}
|
|
}
|
|
|
|
taskContext.Store.InputResponseReceived += handler;
|
|
try
|
|
{
|
|
await taskContext.Store.SetInputRequestsAsync(
|
|
taskContext.TaskId,
|
|
new Dictionary<string, InputRequest> { [requestId] = inputRequest },
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return response.Deserialize(responseTypeInfo)!;
|
|
}
|
|
finally
|
|
{
|
|
taskContext.Store.InputResponseReceived -= handler;
|
|
}
|
|
}
|
|
|
|
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
|
|
{
|
|
if (ClientCapabilities is null)
|
|
{
|
|
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
|
|
}
|
|
|
|
var elicitationCapability = ClientCapabilities.Elicitation;
|
|
if (elicitationCapability is null)
|
|
{
|
|
throw new InvalidOperationException("Client does not support elicitation requests.");
|
|
}
|
|
|
|
if (string.Equals(request.Mode, "form", StringComparison.Ordinal))
|
|
{
|
|
if (request.RequestedSchema is null)
|
|
{
|
|
throw new ArgumentException("Form mode elicitation requests require a requested schema.");
|
|
}
|
|
|
|
if (elicitationCapability.Form is null)
|
|
{
|
|
throw new InvalidOperationException("Client does not support form mode elicitation requests.");
|
|
}
|
|
}
|
|
else if (string.Equals(request.Mode, "url", StringComparison.Ordinal))
|
|
{
|
|
if (request.Url is null)
|
|
{
|
|
throw new ArgumentException("URL mode elicitation requests require a URL.");
|
|
}
|
|
|
|
if (request.ElicitationId is null)
|
|
{
|
|
throw new ArgumentException("URL mode elicitation requests require an elicitation ID.");
|
|
}
|
|
|
|
if (elicitationCapability.Url is null)
|
|
{
|
|
throw new InvalidOperationException("Client does not support URL mode elicitation requests.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Provides an <see cref="IChatClient"/> implementation that's implemented via client sampling.</summary>
|
|
private sealed class SamplingChatClient(McpServer server, JsonSerializerOptions serializerOptions) : IChatClient
|
|
{
|
|
private readonly McpServer _server = server;
|
|
private readonly JsonSerializerOptions _serializerOptions = serializerOptions;
|
|
|
|
/// <inheritdoc/>
|
|
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? chatOptions = null, CancellationToken cancellationToken = default) =>
|
|
_server.SampleAsync(messages, chatOptions, _serializerOptions, cancellationToken);
|
|
|
|
/// <inheritdoc/>
|
|
async IAsyncEnumerable<ChatResponseUpdate> IChatClient.GetStreamingResponseAsync(
|
|
IEnumerable<ChatMessage> messages, ChatOptions? chatOptions, [EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
var response = await GetResponseAsync(messages, chatOptions, cancellationToken).ConfigureAwait(false);
|
|
foreach (var update in response.ToChatResponseUpdates())
|
|
{
|
|
yield return update;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
object? IChatClient.GetService(Type serviceType, object? serviceKey)
|
|
{
|
|
Throw.IfNull(serviceType);
|
|
|
|
return
|
|
serviceKey is not null ? null :
|
|
serviceType.IsInstanceOfType(this) ? this :
|
|
serviceType.IsInstanceOfType(_server) ? _server :
|
|
null;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
void IDisposable.Dispose() { } // nop
|
|
}
|
|
|
|
/// <summary>
|
|
/// Provides an <see cref="ILoggerProvider"/> implementation for creating loggers
|
|
/// that send logging message notifications to the client for logged messages.
|
|
/// </summary>
|
|
private sealed class ClientLoggerProvider(McpServer server) : ILoggerProvider
|
|
{
|
|
private readonly McpServer _server = server;
|
|
|
|
/// <inheritdoc />
|
|
public ILogger CreateLogger(string categoryName)
|
|
{
|
|
Throw.IfNull(categoryName);
|
|
|
|
return new ClientLogger(_server, categoryName);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
void IDisposable.Dispose() { }
|
|
|
|
private sealed class ClientLogger(McpServer server, string categoryName) : ILogger
|
|
{
|
|
private readonly McpServer _server = server;
|
|
private readonly string _categoryName = categoryName;
|
|
|
|
/// <inheritdoc />
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
|
|
null;
|
|
|
|
/// <inheritdoc />
|
|
public bool IsEnabled(LogLevel logLevel) =>
|
|
_server?.LoggingLevel is { } loggingLevel &&
|
|
McpServerImpl.ToLoggingLevel(logLevel) >= loggingLevel;
|
|
|
|
/// <inheritdoc />
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (!IsEnabled(logLevel))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Throw.IfNull(formatter);
|
|
|
|
LogInternal(logLevel, formatter(state, exception));
|
|
|
|
void LogInternal(LogLevel level, string message)
|
|
{
|
|
_ = _server.SendNotificationAsync(NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams
|
|
{
|
|
Level = McpServerImpl.ToLoggingLevel(level),
|
|
Data = JsonSerializer.SerializeToElement(message, McpJsonUtilities.JsonContext.Default.String),
|
|
Logger = _categoryName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|