Skip to content

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,23 @@ public sealed class ElicitResult : Result
/// </remarks>
[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>
[JsonPropertyName("action")]
public string Action { get; set; } = "cancel";

/// <summary>
/// Gets or sets the submitted form data as a typed value.
/// </summary>
[JsonPropertyName("content")]
public T? Content { get; set; }
}
112 changes: 112 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -234,6 +236,116 @@ 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 = BuildRequestSchemaFor<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 };
}

// Compose a JsonObject from the flat content dictionary and deserialize to T
var obj = new JsonObject();
foreach (var kvp in raw.Content)
{
// JsonNode.Parse handles numbers/strings/bools that came back as JsonElement
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 BuildRequestSchemaFor<T>(JsonSerializerOptions serializerOptions)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm guessing we'll want to leverage AIFunction.ReturnJsonSchema, but @eiriktsarpalis would be the best person to ask about this.

Copy link
Member

Choose a reason for hiding this comment

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

Because there's no AIFunction here, we'd probably want to call AIJsonUtilities.CreateJsonSchema directly.

{
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 = CreatePrimitiveSchemaFor(memberType);
if (def is not null)
{
props[name] = def;
}
}

return schema;
}

private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchemaFor(Type type)
{
Type t = Nullable.GetUnderlyingType(type) ?? type;

if (t == typeof(string))
{
return new ElicitRequestParams.StringSchema();
}

if (t.IsEnum)
{
return new ElicitRequestParams.EnumSchema
{
Enum = Enum.GetNames(t)
};
}

if (t == typeof(bool))
{
return new ElicitRequestParams.BooleanSchema();
}

if (t == typeof(byte) || t == typeof(sbyte) || t == typeof(short) || t == typeof(ushort) ||
t == typeof(int) || t == typeof(uint) || t == typeof(long) || t == typeof(ulong))
{
return new ElicitRequestParams.NumberSchema { Type = "integer" };
}

if (t == typeof(float) || t == typeof(double) || t == typeof(decimal))
{
return new ElicitRequestParams.NumberSchema { Type = "number" };
}

// Unsupported type for elicitation schema
return null;
}

private static void ThrowIfSamplingUnsupported(IMcpServer server)
{
if (server.ClientCapabilities?.Sampling is null)
Expand Down
228 changes: 228 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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);

// Expect unsupported members like DateTime to be ignored
Assert.Equal(5, 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;

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)))!,
},
};
},
},
},
});

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);
}

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; }

// Unsupported by elicitation schema; should be ignored
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;
}