Skip to content

Commit 0e1bae4

Browse files
Support multiple contents in sampling results.
1 parent 7cfe0ef commit 0e1bae4

19 files changed

Lines changed: 329 additions & 36 deletions

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ internal static CreateMessageResult ToCreateMessageResult(ChatResponse chatRespo
623623

624624
return new()
625625
{
626-
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
626+
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
627627
Model = chatResponse.ModelId ?? "unknown",
628628
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
629629
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",

src/ModelContextProtocol.Core/Client/McpClientExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chat
703703

704704
return new()
705705
{
706-
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
706+
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
707707
Model = chatResponse.ModelId ?? "unknown",
708708
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
709709
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,13 @@ private void RegisterHandlers(ClientCapabilities capabilities, NotificationHandl
7373

7474
requestHandlers.Set(
7575
RequestMethods.SamplingCreateMessage,
76+
this,
7677
(request, _, cancellationToken) => samplingHandler(
7778
request,
7879
request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
7980
cancellationToken),
80-
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
81-
McpJsonUtilities.JsonContext.Default.CreateMessageResult);
81+
CreateMessageRequestParams.ModelSerializer,
82+
CreateMessageResult.ModelSerializer);
8283
}
8384

8485
if (capabilities.Roots is { } rootsCapability)

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
114114
[JsonSerializable(typeof(CompleteRequestParams))]
115115
[JsonSerializable(typeof(CompleteResult))]
116116
[JsonSerializable(typeof(CreateMessageRequestParams))]
117-
[JsonSerializable(typeof(CreateMessageResult))]
117+
[JsonSerializable(typeof(CreateMessageResultDto_V1))]
118+
[JsonSerializable(typeof(CreateMessageResultDto_V2))]
118119
[JsonSerializable(typeof(ElicitRequestParams))]
119120
[JsonSerializable(typeof(ElicitResult))]
120121
[JsonSerializable(typeof(EmptyResult))]

src/ModelContextProtocol.Core/McpSession.Methods.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,42 @@ internal async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
7272
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
7373
}
7474

75+
/// <summary>
76+
/// Sends a JSON-RPC request and attempts to deserialize the result to <typeparamref name="TResult"/>.
77+
/// </summary>
78+
/// <typeparam name="TParameters">The type of the request parameters to serialize from.</typeparam>
79+
/// <typeparam name="TResult">The type of the result to deserialize to.</typeparam>
80+
/// <param name="method">The JSON-RPC method name to invoke.</param>
81+
/// <param name="parameters">Object representing the request parameters.</param>
82+
/// <param name="parametersSerializer">The request parameter serialization delegate.</param>
83+
/// <param name="resultSerializer">The result deserialization delegate.</param>
84+
/// <param name="requestId">The request id for the request.</param>
85+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
86+
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized result.</returns>
87+
internal async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
88+
string method,
89+
TParameters parameters,
90+
IMcpModelSerializer<TParameters> parametersSerializer,
91+
IMcpModelSerializer<TResult> resultSerializer,
92+
RequestId requestId = default,
93+
CancellationToken cancellationToken = default)
94+
where TResult : notnull
95+
{
96+
Throw.IfNullOrWhiteSpace(method);
97+
Throw.IfNull(parametersSerializer);
98+
Throw.IfNull(resultSerializer);
99+
100+
JsonRpcRequest jsonRpcRequest = new()
101+
{
102+
Id = requestId,
103+
Method = method,
104+
Params = parametersSerializer.Serialize(parameters, this),
105+
};
106+
107+
JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
108+
return resultSerializer.Deserialize(response.Result, this) ?? throw new JsonException("Unexpected JSON result in response.");
109+
}
110+
75111
/// <summary>
76112
/// Sends a parameterless notification to the connected session.
77113
/// </summary>

src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,10 @@ public sealed class CreateMessageRequestParams : RequestParams
100100
/// </summary>
101101
[JsonPropertyName("temperature")]
102102
public float? Temperature { get; init; }
103+
104+
/// <summary>
105+
/// A <see cref="CreateMessageResult"/> serializer that wraps the source generated JsonTypeInfo for the type.
106+
/// </summary>
107+
internal static IMcpModelSerializer<CreateMessageRequestParams> ModelSerializer { get; } =
108+
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams.ToMcpModelSerializer();
103109
}

src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
using System.Text.Json.Serialization;
2-
31
namespace ModelContextProtocol.Protocol;
42

53
/// <summary>
@@ -13,8 +11,24 @@ public sealed class CreateMessageResult : Result
1311
/// <summary>
1412
/// Gets or sets the content of the message.
1513
/// </summary>
16-
[JsonPropertyName("content")]
17-
public required ContentBlock Content { get; init; }
14+
public required List<ContentBlock> Contents
15+
{
16+
get;
17+
init
18+
{
19+
if (value is null or [])
20+
{
21+
throw new ArgumentException(nameof(Contents));
22+
}
23+
24+
field = value;
25+
}
26+
}
27+
28+
/// <summary>
29+
/// Gets or sets the content of the message.
30+
/// </summary>
31+
public ContentBlock Content { get => Contents.First(); init => Contents = [value]; }
1832

1933
/// <summary>
2034
/// Gets or sets the name of the model that generated the message.
@@ -28,7 +42,6 @@ public sealed class CreateMessageResult : Result
2842
/// enabling appropriate handling based on the model's capabilities and characteristics.
2943
/// </para>
3044
/// </remarks>
31-
[JsonPropertyName("model")]
3245
public required string Model { get; init; }
3346

3447
/// <summary>
@@ -42,12 +55,28 @@ public sealed class CreateMessageResult : Result
4255
/// <item><term>stopSequence</term><description>A specific stop sequence was encountered during generation.</description></item>
4356
/// </list>
4457
/// </remarks>
45-
[JsonPropertyName("stopReason")]
4658
public string? StopReason { get; init; }
4759

4860
/// <summary>
4961
/// Gets or sets the role of the user who generated the message.
5062
/// </summary>
51-
[JsonPropertyName("role")]
5263
public required Role Role { get; init; }
53-
}
64+
65+
/// <summary>
66+
/// A <see cref="CreateMessageResult"/> serializer that delegates to the appropriate versioned DTO serializer
67+
/// </summary>
68+
internal static IMcpModelSerializer<CreateMessageResult> ModelSerializer { get; } =
69+
McpModelSerializer.CreateDelegatingSerializer(endpoint =>
70+
{
71+
if (endpoint?.NegotiatedProtocolVersion is string version &&
72+
DateTime.Parse(version) < new DateTime(2025, 09, 18)) // A hypothetical future version
73+
{
74+
// The negotiated protocol version is before 2025-09-18, so we need to use the V1 serializer.
75+
return CreateMessageResultDto_V1.ModelSerializer;
76+
}
77+
else
78+
{
79+
return CreateMessageResultDto_V2.ModelSerializer;
80+
}
81+
});
82+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Text.Json.Nodes;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ModelContextProtocol.Protocol;
5+
6+
internal sealed class CreateMessageResultDto_V2 // V1, V2, V3, etc. as a placeholder naming convention.
7+
// Could also be the protocol version introducing the breaking change
8+
{
9+
[JsonPropertyName("content")]
10+
public required List<ContentBlock> Content { get; init; }
11+
12+
[JsonPropertyName("model")]
13+
public required string Model { get; init; }
14+
15+
[JsonPropertyName("stopReason")]
16+
public string? StopReason { get; init; }
17+
18+
[JsonPropertyName("role")]
19+
public required Role Role { get; init; }
20+
21+
[JsonPropertyName("_meta")]
22+
public JsonObject? Meta { get; init; }
23+
24+
/// <summary>
25+
/// The serializer for <see cref="CreateMessageResult"/> using this DTO.
26+
/// </summary>
27+
public static IMcpModelSerializer<CreateMessageResult> ModelSerializer { get; } =
28+
McpModelSerializer.CreateDtoSerializer<CreateMessageResult, CreateMessageResultDto_V2>(
29+
toDto: static model => new ()
30+
{
31+
Content = model.Contents,
32+
Model = model.Model,
33+
StopReason = model.StopReason,
34+
Role = model.Role,
35+
Meta = model.Meta
36+
},
37+
fromDto: static dto => new()
38+
{
39+
Contents = dto.Content,
40+
Model = dto.Model,
41+
StopReason = dto.StopReason,
42+
Role = dto.Role,
43+
Meta = dto.Meta
44+
},
45+
McpJsonUtilities.JsonContext.Default.CreateMessageResultDto_V2);
46+
}
47+
48+
internal sealed class CreateMessageResultDto_V1 // V1, V2, V3, etc. as a placeholder naming convention.
49+
{
50+
[JsonPropertyName("content")]
51+
public required ContentBlock Content { get; init; }
52+
53+
[JsonPropertyName("model")]
54+
public required string Model { get; init; }
55+
56+
[JsonPropertyName("stopReason")]
57+
public string? StopReason { get; init; }
58+
59+
[JsonPropertyName("role")]
60+
public required Role Role { get; init; }
61+
62+
[JsonPropertyName("_meta")]
63+
public JsonObject? Meta { get; init; }
64+
65+
/// <summary>
66+
/// The serializer for <see cref="CreateMessageResult"/> using this DTO.
67+
/// </summary>
68+
public static IMcpModelSerializer<CreateMessageResult> ModelSerializer { get; } =
69+
McpModelSerializer.CreateDtoSerializer<CreateMessageResult, CreateMessageResultDto_V1>(
70+
toDto: static model => new()
71+
{
72+
Content = model.Content,
73+
Model = model.Model,
74+
StopReason = model.StopReason,
75+
Role = model.Role,
76+
Meta = model.Meta
77+
},
78+
fromDto: static dto => new()
79+
{
80+
Contents = [dto.Content],
81+
Model = dto.Model,
82+
StopReason = dto.StopReason,
83+
Role = dto.Role,
84+
Meta = dto.Meta
85+
},
86+
McpJsonUtilities.JsonContext.Default.CreateMessageResultDto_V1);
87+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Runtime.CompilerServices;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
using System.Text.Json.Serialization.Metadata;
5+
6+
namespace ModelContextProtocol.Protocol;
7+
8+
/// <summary>
9+
/// An abstraction for serializing and deserializing MCP model objects to/from JSON,
10+
/// </summary>
11+
/// <typeparam name="TModel">The model type being serialized.</typeparam>
12+
internal interface IMcpModelSerializer<TModel>
13+
{
14+
public JsonNode? Serialize(TModel? model, McpSession session);
15+
public TModel? Deserialize(JsonNode? node, McpSession session);
16+
}
17+
18+
/// <summary>
19+
/// Defines a set of factory methods for creating <see cref="IMcpModelSerializer{TModel}"/> instances.
20+
/// </summary>
21+
internal static class McpModelSerializer
22+
{
23+
/// <summary>
24+
/// Creates an MCP model serializer that delegates to different serializers based on the MCP session.
25+
/// </summary>
26+
public static IMcpModelSerializer<TModel> CreateDelegatingSerializer<TModel>(Func<McpSession, IMcpModelSerializer<TModel>> selector) =>
27+
new DelegatingMcpModelSerializer<TModel>(selector);
28+
29+
/// <summary>
30+
/// Creates an MCP model serializer mapped from a <see cref="JsonTypeInfo{T}"/>.
31+
/// </summary>
32+
/// <typeparam name="TModel"></typeparam>
33+
/// <param name="typeInfo"></param>
34+
/// <returns></returns>
35+
public static IMcpModelSerializer<TModel> ToMcpModelSerializer<TModel>(this JsonTypeInfo<TModel> typeInfo) =>
36+
new JsonTypeInfoMcpModelSerializer<TModel>(typeInfo);
37+
38+
/// <summary>
39+
/// Creates an MCP model serializer that maps between a model type and a DTO type for serialization.
40+
/// </summary>
41+
/// <typeparam name="TModel">The model type to serialize.</typeparam>
42+
/// <typeparam name="TDto">The DTO used to drive serialization.</typeparam>
43+
/// <param name="toDto">The model-to-dto mapper.</param>
44+
/// <param name="fromDto">The dto-to-model inverse mapper.</param>
45+
/// <param name="dtoTypeInfo">The <see cref="JsonTypeInfo"/> governing serialization of the DTO type.</param>
46+
public static IMcpModelSerializer<TModel> CreateDtoSerializer<TModel, TDto>(
47+
Func<TModel, TDto> toDto,
48+
Func<TDto, TModel> fromDto,
49+
JsonTypeInfo<TDto> dtoTypeInfo) =>
50+
new DtoMappingMcpModelSerializer<TModel, TDto>(toDto, fromDto, dtoTypeInfo);
51+
52+
private sealed class JsonTypeInfoMcpModelSerializer<TModel>(JsonTypeInfo<TModel> typeInfo) : IMcpModelSerializer<TModel>
53+
{
54+
public TModel? Deserialize(JsonNode? node, McpSession _) => JsonSerializer.Deserialize(node, typeInfo);
55+
public JsonNode? Serialize(TModel? model, McpSession _) => model is null ? null : JsonSerializer.SerializeToNode(model, typeInfo);
56+
}
57+
58+
private sealed class DelegatingMcpModelSerializer<TModel>(Func<McpSession, IMcpModelSerializer<TModel>> selector) : IMcpModelSerializer<TModel>
59+
{
60+
public TModel? Deserialize(JsonNode? node, McpSession session)
61+
{
62+
var serializer = selector(session);
63+
return serializer.Deserialize(node, session);
64+
}
65+
66+
public JsonNode? Serialize(TModel? model, McpSession session)
67+
{
68+
var serializer = selector(session);
69+
return serializer.Serialize(model, session);
70+
}
71+
}
72+
73+
private sealed class DtoMappingMcpModelSerializer<TModel, TDto>(Func<TModel, TDto> toDto, Func<TDto, TModel> fromDto, JsonTypeInfo<TDto> dtoTypeInfo) : IMcpModelSerializer<TModel>
74+
{
75+
public JsonNode? Serialize(TModel? model, McpSession _)
76+
{
77+
if (model is null)
78+
{
79+
return null;
80+
}
81+
82+
TDto dto = toDto(model);
83+
return JsonSerializer.SerializeToNode(dto, dtoTypeInfo);
84+
}
85+
86+
public TModel? Deserialize(JsonNode? node, McpSession _)
87+
{
88+
TDto? dto = JsonSerializer.Deserialize(node, dtoTypeInfo);
89+
return dto is null ? default : fromDto(dto);
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)