Skip to content
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6c04345
Add ElicitAsync<T> (#630)
mehrandvd Aug 18, 2025
839e5f9
Merge branch 'main' into add-elicitasync
mehrandvd Aug 21, 2025
9f1100b
Fix the enum issue. for ElicitAsync<T>.
mehrandvd Aug 22, 2025
0259465
Use AIJsonUtilities.CreateJsonSchema to create PrimitiveSchemaDefinit…
mehrandvd Aug 23, 2025
309b2da
Merge latest main.
mehrandvd Aug 26, 2025
e520a33
Simplify ElicitAsync for schema validation. #630
mehrandvd Aug 27, 2025
86ecb95
Merge branch 'modelcontextprotocol:main' into add-elicitasync
mehrandvd Aug 27, 2025
3d5a1d6
Add error handling for unsupported elicitation types #630
mehrandvd Aug 27, 2025
ade05e6
Validate generic types in BuildRequestSchema #630
mehrandvd Aug 28, 2025
a2fdf0a
Move nullable types handling logic to ElicitationRequestParams.Covert…
mehrandvd Sep 2, 2025
ec19bd5
Add schema validation for elicitation requests
mehrandvd Sep 2, 2025
85e8e26
Merge branch 'modelcontextprotocol:main' into add-elicitasync
mehrandvd Sep 3, 2025
49c3fa9
Refactor nullable type pattern matching. #630
mehrandvd Sep 3, 2025
f31815e
Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
mehrandvd Sep 4, 2025
1abcfde
Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
mehrandvd Sep 4, 2025
400b14d
Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
mehrandvd Sep 4, 2025
0ac795b
Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
mehrandvd Sep 4, 2025
c78da12
Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
mehrandvd Sep 4, 2025
6457cbd
Add ElicitResultSchemaCache. #630
mehrandvd Sep 5, 2025
dfaedf3
Prepopulate elicit schema validation logic. #630
mehrandvd Sep 5, 2025
18eae33
Merge branch 'modelcontextprotocol:main' into add-elicitasync
mehrandvd Sep 5, 2025
c8e9bc8
Use Nullable.GetUnderlyingType to handle nullable types on elicitatio…
mehrandvd Sep 5, 2025
7f554ed
Rename static field.
mehrandvd Sep 5, 2025
c8b3a08
Fix static field renamings. #630
mehrandvd Sep 5, 2025
a227475
Make BuildRequestSchema non-generic. #630
mehrandvd Sep 5, 2025
d141073
Avoid closure allocation for serializerOptions on netcore #630
mehrandvd Sep 6, 2025
2f1dcf0
Merge branch 'modelcontextprotocol:main' into add-elicitasync
mehrandvd Sep 8, 2025
c5419c6
Refactor ElicitRequestParams and McpServerExtensions. #630
mehrandvd Sep 8, 2025
1a32217
Remove reduntant checks. #630
mehrandvd Sep 8, 2025
4f20c12
Add IsAccepted property and update ElicitAsync return type
mehrandvd Sep 8, 2025
9419e11
Rename to s_elicitAllowedProperties
mehrandvd Sep 8, 2025
4775040
Fix renaming s_elicitAllowedProperties. #630
mehrandvd Sep 9, 2025
a3b6c11
Remove unnecessary json attributes. #630
mehrandvd Sep 9, 2025
c5a22f6
Improve xml comment
mehrandvd Sep 10, 2025
444dea7
Remove extra IsAccepted property. #630
mehrandvd Sep 11, 2025
1d6c5c7
Use IsAccepted for checks.
mehrandvd Sep 12, 2025
54e0f12
Add IsAccepted to non-generic ElicitResult.
mehrandvd Sep 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public sealed class ElicitResult : Result
[JsonPropertyName("action")]
public string Action { get; set; } = "cancel";

/// <summary>
/// Convenience indicator for whether the elicitation was accepted by the user.
/// </summary>
[JsonIgnore]
public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Gets or sets the submitted form data.
/// </summary>
Expand All @@ -48,3 +54,25 @@ public sealed class ElicitResult : Result
[JsonPropertyName("content")]
public IDictionary<string, JsonElement>? Content { get; set; }
}

/// <summary>
/// Represents the client's response to an elicitation request, with typed content payload.
/// </summary>
/// <typeparam name="T">The type of the expected content payload.</typeparam>
public sealed class ElicitResult<T> : Result
{
/// <summary>
/// Gets or sets the user action in response to the elicitation.
/// </summary>
public string Action { get; set; } = "cancel";

/// <summary>
/// Convenience indicator for whether the elicitation was accepted by the user.
/// </summary>
public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Gets or sets the submitted form data as a typed value.
/// </summary>
public T? Content { get; set; }
}
195 changes: 195 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
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;

Expand All @@ -12,6 +16,13 @@ namespace ModelContextProtocol.Server;
/// </summary>
public static class McpServerExtensions
{
/// <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>
/// Requests to sample an LLM via the client using the specified request parameters.
/// </summary>
Expand Down Expand Up @@ -234,6 +245,190 @@ public static ValueTask<ElicitResult> ElicitAsync(
cancellationToken: cancellationToken);
}

/// <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="server">The server initiating the request.</param>
/// <param name="message">The message to present to the user.</param>
/// <param name="serializerOptions">Serializer options that influence property naming and deserialization.</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>
/// <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 static async ValueTask<ElicitResult<T>> ElicitAsync<T>(
this IMcpServer server,
string message,
JsonSerializerOptions? serializerOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(server);
ThrowIfElicitationUnsupported(server);

serializerOptions ??= McpJsonUtilities.DefaultOptions;
serializerOptions.MakeReadOnly();

var dict = s_elicitResultSchemaCache.GetValue(serializerOptions, _ => new());

#if NET
var schema = dict.GetOrAdd(typeof(T), static (t, s) => BuildRequestSchema(t, s), serializerOptions);
#else
var schema = dict.GetOrAdd(typeof(T), type => BuildRequestSchema(type, serializerOptions));
#endif

var request = new ElicitRequestParams
{
Message = message,
RequestedSchema = schema,
};

var raw = await server.ElicitAsync(request, cancellationToken).ConfigureAwait(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than using this helper that's deserializing the response as a non-generic ElicitResponse, and then needing to manually copy all the content into a JsonObject and deserialize that, could this instead just write out the additional few lines to call the underlying helper with the the generic ElicitResponse<T>?


if (!string.Equals(raw.Action, "accept", StringComparison.OrdinalIgnoreCase) || raw.Content is null)
{
return new ElicitResult<T> { Action = raw.Action, Content = default };
}

var obj = new JsonObject();
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>
/// 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="McpException"></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 McpException($"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="McpException">Thrown when the type is not supported.</exception>
private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions)
{
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
{
throw new McpException($"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 McpException($"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 McpException(error);
}

var primitiveSchemaDefinition =
jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition);

if (primitiveSchemaDefinition is null)
throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests.");

return primitiveSchemaDefinition;
}

/// <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 static void ThrowIfSamplingUnsupported(IMcpServer server)
{
if (server.ClientCapabilities?.Sampling is null)
Expand Down
Loading
Loading