-
Notifications
You must be signed in to change notification settings - Fork 495
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 6 commits
6c04345
839e5f9
9f1100b
0259465
309b2da
e520a33
86ecb95
3d5a1d6
ade05e6
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 |
---|---|---|
|
@@ -4,6 +4,8 @@ | |
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; | ||
|
||
|
@@ -234,6 +236,104 @@ 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) where T : class | ||
{ | ||
Throw.IfNull(server); | ||
ThrowIfElicitationUnsupported(server); | ||
|
||
serializerOptions ??= McpJsonUtilities.DefaultOptions; | ||
serializerOptions.MakeReadOnly(); | ||
|
||
var schema = BuildRequestSchema<T>(serializerOptions); | ||
|
||
var request = new ElicitRequestParams | ||
{ | ||
Message = message, | ||
RequestedSchema = schema, | ||
}; | ||
|
||
var raw = await server.ElicitAsync(request, cancellationToken).ConfigureAwait(false); | ||
|
||
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 }; | ||
} | ||
|
||
private static ElicitRequestParams.RequestSchema BuildRequestSchema<T>(JsonSerializerOptions serializerOptions) | ||
{ | ||
var schema = new ElicitRequestParams.RequestSchema(); | ||
var props = schema.Properties; | ||
|
||
JsonTypeInfo<T> typeInfo = serializerOptions.GetTypeInfo<T>(); | ||
foreach (JsonPropertyInfo pi in typeInfo.Properties) | ||
{ | ||
var memberType = pi.PropertyType; | ||
string name = pi.Name; // serialized name honoring naming policy/attributes | ||
var def = CreatePrimitiveSchema(memberType, serializerOptions); | ||
if (def is not null) | ||
{ | ||
props[name] = def; | ||
} | ||
} | ||
|
||
return schema; | ||
} | ||
|
||
private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) | ||
{ | ||
Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; | ||
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. Please remove this normalization step. 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. If I remove this, nullable types such as public class Converter : JsonConverter<PrimitiveSchemaDefinition>
{
public override PrimitiveSchemaDefinition? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// ...
switch (propertyName)
{
case "type":
type = reader.GetString(); // THROWS exception as it is an array for nullable types like bool?
break; To resolve this, I could modify the converter to accommodate nullable types in this way: case "type":
if (reader.TokenType == JsonTokenType.String)
{
type = reader.GetString();
}
else if (reader.TokenType == JsonTokenType.StartArray)
{
var types = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.StringArray);
if (types is [var nullableType, "null"])
{
type = nullableType;
}
}
break; Does this make sense? 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. @eiriktsarpalis any thoughts on this? 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. Assuming the elicitation spec permits arrays in type keywords, then I think that would be the right thing to do. |
||
|
||
JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(underlyingType); | ||
|
||
if (typeInfo.Kind != JsonTypeInfoKind.None) | ||
{ | ||
return null; | ||
mehrandvd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
var jsonElement = AIJsonUtilities.CreateJsonSchema(underlyingType, serializerOptions: serializerOptions); | ||
|
||
if (jsonElement.TryGetProperty("type", out var typeElement)) | ||
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. What if the schema is just |
||
{ | ||
var typeValue = typeElement.GetString(); | ||
if (typeValue is "string" or "number" or "integer" or "boolean") | ||
{ | ||
var primitiveSchemaDefinition = | ||
jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); | ||
return primitiveSchemaDefinition; | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private static void ThrowIfSamplingUnsupported(IMcpServer server) | ||
{ | ||
if (server.ClientCapabilities?.Sampling is null) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,239 @@ | ||
using Microsoft.Extensions.DependencyInjection; | ||
using ModelContextProtocol.Client; | ||
using ModelContextProtocol.Protocol; | ||
using ModelContextProtocol.Server; | ||
using System.Text.Json; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace ModelContextProtocol.Tests.Configuration; | ||
|
||
public partial class ElicitationTypedTests : ClientServerTestBase | ||
{ | ||
public ElicitationTypedTests(ITestOutputHelper testOutputHelper) | ||
: base(testOutputHelper) | ||
{ | ||
} | ||
|
||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) | ||
{ | ||
mcpServerBuilder.WithCallToolHandler(async (request, cancellationToken) => | ||
{ | ||
Assert.NotNull(request.Params); | ||
|
||
if (request.Params!.Name == "TestElicitationTyped") | ||
{ | ||
var result = await request.Server.ElicitAsync<SampleForm>( | ||
message: "Please provide more information.", | ||
serializerOptions: ElicitationTypedDefaultJsonContext.Default.Options, | ||
cancellationToken: CancellationToken.None); | ||
|
||
Assert.Equal("accept", result.Action); | ||
Assert.NotNull(result.Content); | ||
Assert.Equal("Alice", result.Content!.Name); | ||
Assert.Equal(30, result.Content!.Age); | ||
Assert.True(result.Content!.Active); | ||
Assert.Equal(SampleRole.Admin, result.Content!.Role); | ||
Assert.Equal(99.5, result.Content!.Score); | ||
} | ||
else if (request.Params!.Name == "TestElicitationTypedCamel") | ||
{ | ||
var result = await request.Server.ElicitAsync<CamelForm>( | ||
message: "Please provide more information.", | ||
serializerOptions: ElicitationTypedCamelJsonContext.Default.Options, | ||
cancellationToken: CancellationToken.None); | ||
|
||
Assert.Equal("accept", result.Action); | ||
Assert.NotNull(result.Content); | ||
Assert.Equal("Bob", result.Content!.FirstName); | ||
Assert.Equal(90210, result.Content!.ZipCode); | ||
Assert.False(result.Content!.IsAdmin); | ||
} | ||
else | ||
{ | ||
Assert.Fail($"Unexpected tool name: {request.Params!.Name}"); | ||
} | ||
|
||
return new CallToolResult | ||
{ | ||
Content = [new TextContentBlock { Text = "success" }], | ||
}; | ||
}); | ||
} | ||
|
||
[Fact] | ||
public async Task Can_Elicit_Typed_Information() | ||
{ | ||
await using IMcpClient client = await CreateMcpClientForServer(new McpClientOptions | ||
{ | ||
Capabilities = new() | ||
{ | ||
Elicitation = new() | ||
{ | ||
ElicitationHandler = async (request, cancellationToken) => | ||
{ | ||
Assert.NotNull(request); | ||
Assert.Equal("Please provide more information.", request.Message); | ||
|
||
Assert.Equal(6, request.RequestedSchema.Properties.Count); | ||
|
||
foreach (var entry in request.RequestedSchema.Properties) | ||
{ | ||
var key = entry.Key; | ||
var value = entry.Value; | ||
switch (key) | ||
{ | ||
case nameof(SampleForm.Name): | ||
var stringSchema = Assert.IsType<ElicitRequestParams.StringSchema>(value); | ||
Assert.Equal("string", stringSchema.Type); | ||
break; | ||
|
||
case nameof(SampleForm.Age): | ||
var intSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(value); | ||
Assert.Equal("integer", intSchema.Type); | ||
break; | ||
|
||
case nameof(SampleForm.Active): | ||
var boolSchema = Assert.IsType<ElicitRequestParams.BooleanSchema>(value); | ||
Assert.Equal("boolean", boolSchema.Type); | ||
break; | ||
|
||
case nameof(SampleForm.Role): | ||
var enumSchema = Assert.IsType<ElicitRequestParams.EnumSchema>(value); | ||
Assert.Equal("string", enumSchema.Type); | ||
Assert.Equal([nameof(SampleRole.User), nameof(SampleRole.Admin)], enumSchema.Enum); | ||
break; | ||
|
||
case nameof(SampleForm.Score): | ||
var numSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(value); | ||
Assert.Equal("number", numSchema.Type); | ||
break; | ||
|
||
case nameof(SampleForm.Created): | ||
var dateTimeSchema = Assert.IsType<ElicitRequestParams.StringSchema>(value); | ||
Assert.Equal("string", dateTimeSchema.Type); | ||
Assert.Equal("date-time", dateTimeSchema.Format); | ||
|
||
break; | ||
|
||
default: | ||
Assert.Fail($"Unexpected property in schema: {key}"); | ||
break; | ||
} | ||
} | ||
|
||
return new ElicitResult | ||
{ | ||
Action = "accept", | ||
Content = new Dictionary<string, JsonElement> | ||
{ | ||
[nameof(SampleForm.Name)] = (JsonElement)JsonSerializer.Deserialize(""" | ||
"Alice" | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
[nameof(SampleForm.Age)] = (JsonElement)JsonSerializer.Deserialize(""" | ||
30 | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
[nameof(SampleForm.Active)] = (JsonElement)JsonSerializer.Deserialize(""" | ||
true | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
[nameof(SampleForm.Role)] = (JsonElement)JsonSerializer.Deserialize(""" | ||
"Admin" | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
[nameof(SampleForm.Score)] = (JsonElement)JsonSerializer.Deserialize(""" | ||
99.5 | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
[nameof(SampleForm.Created)] = (JsonElement)JsonSerializer.Deserialize(""" | ||
"2023-08-27T03:05:00" | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
}, | ||
}; | ||
}, | ||
}, | ||
}, | ||
}); | ||
|
||
var result = await client.CallToolAsync("TestElicitationTyped", cancellationToken: TestContext.Current.CancellationToken); | ||
|
||
Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text); | ||
} | ||
|
||
[Fact] | ||
public async Task Elicit_Typed_Respects_NamingPolicy() | ||
{ | ||
await using IMcpClient client = await CreateMcpClientForServer(new McpClientOptions | ||
{ | ||
Capabilities = new() | ||
{ | ||
Elicitation = new() | ||
{ | ||
ElicitationHandler = async (request, cancellationToken) => | ||
{ | ||
Assert.NotNull(request); | ||
Assert.Equal("Please provide more information.", request.Message); | ||
|
||
// Expect camelCase names based on serializer options | ||
Assert.Contains("firstName", request.RequestedSchema.Properties.Keys); | ||
Assert.Contains("zipCode", request.RequestedSchema.Properties.Keys); | ||
Assert.Contains("isAdmin", request.RequestedSchema.Properties.Keys); | ||
|
||
return new ElicitResult | ||
{ | ||
Action = "accept", | ||
Content = new Dictionary<string, JsonElement> | ||
{ | ||
["firstName"] = (JsonElement)JsonSerializer.Deserialize(""" | ||
"Bob" | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
["zipCode"] = (JsonElement)JsonSerializer.Deserialize(""" | ||
90210 | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
["isAdmin"] = (JsonElement)JsonSerializer.Deserialize(""" | ||
false | ||
""", McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, | ||
}, | ||
}; | ||
}, | ||
}, | ||
}, | ||
}); | ||
|
||
var result = await client.CallToolAsync("TestElicitationTypedCamel", cancellationToken: TestContext.Current.CancellationToken); | ||
Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text); | ||
} | ||
|
||
[JsonConverter(typeof(CustomizableJsonStringEnumConverter<SampleRole>))] | ||
|
||
public enum SampleRole | ||
{ | ||
User, | ||
Admin, | ||
} | ||
|
||
public sealed class SampleForm | ||
{ | ||
public string? Name { get; set; } | ||
public int Age { get; set; } | ||
public bool? Active { get; set; } | ||
public SampleRole Role { get; set; } | ||
public double Score { get; set; } | ||
|
||
|
||
public DateTime Created { get; set; } | ||
} | ||
|
||
public sealed class CamelForm | ||
{ | ||
public string? FirstName { get; set; } | ||
public int ZipCode { get; set; } | ||
public bool IsAdmin { get; set; } | ||
} | ||
|
||
[JsonSerializable(typeof(SampleForm))] | ||
[JsonSerializable(typeof(SampleRole))] | ||
[JsonSerializable(typeof(JsonElement))] | ||
internal partial class ElicitationTypedDefaultJsonContext : JsonSerializerContext; | ||
|
||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] | ||
[JsonSerializable(typeof(CamelForm))] | ||
[JsonSerializable(typeof(JsonElement))] | ||
internal partial class ElicitationTypedCamelJsonContext : JsonSerializerContext; | ||
} |
Uh oh!
There was an error while loading. Please reload this page.