Skip to content
19 changes: 19 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,22 @@ 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>
[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; }
}
100 changes: 100 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,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;
Copy link
Member

Choose a reason for hiding this comment

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

Please remove this normalization step.

Copy link
Author

Choose a reason for hiding this comment

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

If I remove this, nullable types such as bool? will result in a resolved schema represented as an array with "type": ["boolean", "null"] (instead of "type": "boolean"). This cannot be deserialized into a PrimitiveSchemaDefinition because the converter expects the types to be strings and does not support arrays:

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?

Copy link
Author

Choose a reason for hiding this comment

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

@eiriktsarpalis any thoughts on this?

Copy link
Member

Choose a reason for hiding this comment

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

var jsonElement = AIJsonUtilities.CreateJsonSchema(underlyingType, serializerOptions: serializerOptions);

if (jsonElement.TryGetProperty("type", out var typeElement))
Copy link
Member

Choose a reason for hiding this comment

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

What if the schema is just {} or true? What if the schema contains a supported type keyword but then also contains other unsupported keywords? Shouldn't we be validating those as well?

{
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)
Expand Down
239 changes: 239 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs
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;
}