-
Notifications
You must be signed in to change notification settings - Fork 500
Add ElicitAsync<T> (#630) #715
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 33 commits
6c04345
839e5f9
9f1100b
0259465
309b2da
e520a33
86ecb95
3d5a1d6
ade05e6
a2fdf0a
ec19bd5
85e8e26
49c3fa9
f31815e
1abcfde
400b14d
0ac795b
c78da12
6457cbd
dfaedf3
18eae33
c8e9bc8
7f554ed
c8b3a08
a227475
d141073
2f1dcf0
c5419c6
1a32217
4f20c12
9419e11
4775040
a3b6c11
c5a22f6
444dea7
1d6c5c7
54e0f12
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
|
||
|
@@ -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> | ||
|
@@ -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(); | ||
eiriktsarpalis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
if (!string.Equals(raw.Action, "accept", StringComparison.OrdinalIgnoreCase) || raw.Content is null) | ||
eiriktsarpalis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
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>()); | ||
eiriktsarpalis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
mehrandvd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
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) | ||
|
Uh oh!
There was an error while loading. Please reload this page.