Skip to content

Commit 6d135bb

Browse files
Support multiple contents in sampling results.
1 parent d54a239 commit 6d135bb

17 files changed

Lines changed: 329 additions & 33 deletions

src/ModelContextProtocol.Core/Client/McpClient.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ public McpClient(IClientTransport clientTransport, McpClientOptions? options, IL
5555

5656
RequestHandlers.Set(
5757
RequestMethods.SamplingCreateMessage,
58+
this,
5859
(request, _, cancellationToken) => samplingHandler(
5960
request,
6061
request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
6162
cancellationToken),
62-
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
63-
McpJsonUtilities.JsonContext.Default.CreateMessageResult);
63+
CreateMessageRequestParams.ModelSerializer,
64+
CreateMessageResult.ModelSerializer);
6465
}
6566

6667
if (capabilities.Roots is { } rootsCapability)

src/ModelContextProtocol.Core/Client/McpClientExtensions.cs

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

958958
return new()
959959
{
960-
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
960+
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
961961
Model = chatResponse.ModelId ?? "unknown",
962962
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
963963
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",

src/ModelContextProtocol.Core/McpEndpointExtensions.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,45 @@ internal static async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
9090
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
9191
}
9292

93+
/// <summary>
94+
/// Sends a JSON-RPC request and attempts to deserialize the result to <typeparamref name="TResult"/>.
95+
/// </summary>
96+
/// <typeparam name="TParameters">The type of the request parameters to serialize from.</typeparam>
97+
/// <typeparam name="TResult">The type of the result to deserialize to.</typeparam>
98+
/// <param name="endpoint">The MCP client or server instance.</param>
99+
/// <param name="method">The JSON-RPC method name to invoke.</param>
100+
/// <param name="parameters">Object representing the request parameters.</param>
101+
/// <param name="parametersSerializer">The request parameter serialization delegate.</param>
102+
/// <param name="resultSerializer">The result deserialization delegate.</param>
103+
/// <param name="requestId">The request id for the request.</param>
104+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
105+
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized result.</returns>
106+
internal static async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
107+
this IMcpEndpoint endpoint,
108+
string method,
109+
TParameters parameters,
110+
IMcpModelSerializer<TParameters> parametersSerializer,
111+
IMcpModelSerializer<TResult> resultSerializer,
112+
RequestId requestId = default,
113+
CancellationToken cancellationToken = default)
114+
where TResult : notnull
115+
{
116+
Throw.IfNull(endpoint);
117+
Throw.IfNullOrWhiteSpace(method);
118+
Throw.IfNull(parametersSerializer);
119+
Throw.IfNull(resultSerializer);
120+
121+
JsonRpcRequest jsonRpcRequest = new()
122+
{
123+
Id = requestId,
124+
Method = method,
125+
Params = parametersSerializer.Serialize(parameters, endpoint),
126+
};
127+
128+
JsonRpcResponse response = await endpoint.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
129+
return resultSerializer.Deserialize(response.Result, endpoint) ?? throw new JsonException("Unexpected JSON result in response.");
130+
}
131+
93132
/// <summary>
94133
/// Sends a parameterless notification to the connected endpoint.
95134
/// </summary>

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/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, IMcpEndpoint endpoint);
15+
public TModel? Deserialize(JsonNode? node, IMcpEndpoint endpoint);
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 endpoint.
25+
/// </summary>
26+
public static IMcpModelSerializer<TModel> CreateDelegatingSerializer<TModel>(Func<IMcpEndpoint, 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, IMcpEndpoint _) => JsonSerializer.Deserialize(node, typeInfo);
55+
public JsonNode? Serialize(TModel? model, IMcpEndpoint _) => model is null ? null : JsonSerializer.SerializeToNode(model, typeInfo);
56+
}
57+
58+
private sealed class DelegatingMcpModelSerializer<TModel>(Func<IMcpEndpoint, IMcpModelSerializer<TModel>> selector) : IMcpModelSerializer<TModel>
59+
{
60+
public TModel? Deserialize(JsonNode? node, IMcpEndpoint endpoint)
61+
{
62+
var serializer = selector(endpoint);
63+
return serializer.Deserialize(node, endpoint);
64+
}
65+
66+
public JsonNode? Serialize(TModel? model, IMcpEndpoint endpoint)
67+
{
68+
var serializer = selector(endpoint);
69+
return serializer.Serialize(model, endpoint);
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, IMcpEndpoint _)
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, IMcpEndpoint _)
87+
{
88+
TDto? dto = JsonSerializer.Deserialize(node, dtoTypeInfo);
89+
return dto is null ? default : fromDto(dto);
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)